diff --git a/.ai/skills/new-command.md b/.ai/skills/new-command.md index 967f7e8..9026184 100644 --- a/.ai/skills/new-command.md +++ b/.ai/skills/new-command.md @@ -164,7 +164,7 @@ where Esc steps back one prompt while Ctrl+C exits the whole flow. **Multi-step wizards — ALWAYS use the shared wizard engine.** Do NOT hand-roll a step loop. Every multi-step interactive flow goes through -`github.com/verda-cloud/verdagostack/pkg/tui/wizard` so they all share one look +`github.com/verda-cloud/verda-cli/pkg/tui/wizard` so they all share one look (progress bar + hint bar), Esc=back, and Ctrl+C handling. Reference flows: `cmd/s3/wizard.go` (`buildConfigureFlow`), `cmd/s3/move_wizard.go` (`buildMoveFlow`), `cmd/vm/wizard.go`. @@ -307,7 +307,7 @@ The pager auto-detects: prints directly if content fits terminal, otherwise show - `cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util"` -- Factory, IOStreams, DebugJSON, helpers - `"github.com/verda-cloud/verdacloud-sdk-go/pkg/verda"` -- SDK client and types -- `"github.com/verda-cloud/verdagostack/pkg/tui"` -- Prompter, Status, pager options +- `"github.com/verda-cloud/verda-cli/pkg/tui"` -- Prompter, Status, pager options - `"charm.land/lipgloss/v2"` -- Terminal styling - `"github.com/spf13/cobra"` -- Command framework diff --git a/.gitignore b/.gitignore index 33082de..4c88ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -386,4 +386,7 @@ docs/plans/ # AI agent project-level configs (installed by users, not shipped) .cursor/ .claude/skills/ -.gitnexus + +# The boilerplate *testing pattern above is for loose test junk; Go +# pkg/.../testing/ dirs are library code (it once swallowed pkg/tui/testing). +!**/testing/ diff --git a/.golangci.yaml b/.golangci.yaml index f3e77a6..b70bc89 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -95,6 +95,24 @@ linters: linters: - revive text: "var-naming" + # pkg/ is in-tree library code (copied from verdagostack v1.4.2 with only + # import-path rewrites). Style/performance heuristics that fight the + # Bubble Tea idiom (value-receiver models, min/max param names in public + # option types) are relaxed here; correctness linters still apply. + # prealloc: flagged differently across golangci-lint versions (CI pins + # v2.5.0, local newer) — keep pkg/ out of that skew. + - path: pkg/ + linters: + - goconst + - gocritic + - gocyclo + - misspell + - nestif + - nilerr + - perfsprint + - prealloc + - predeclared + - revive - linters: - nolintlint text: "gosec" diff --git a/.goreleaser.yml b/.goreleaser.yml index 95f8bb1..0ce9f01 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -9,7 +9,7 @@ builds: binary: verda main: ./cmd/verda/ ldflags: - - -s -w -X github.com/verda-cloud/verdagostack/pkg/version.gitVersion={{ .Version }} + - -s -w -X github.com/verda-cloud/verda-cli/pkg/version.gitVersion={{ .Version }} env: - CGO_ENABLED=0 goos: diff --git a/AGENTS.md b/AGENTS.md index bcaf21c..a19621c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing - **Preserve dual mode** — every command must work interactive AND non-interactive. Never build one without the other - **Interactive hint bar** — every direct `prompter.Select(...)` outside the wizard engine must pass `tui.WithShowHints(true)` (and the equivalent option on `MultiSelect`) so the prompt renders its key hints below the choices. Wizard steps are exempt — the composite already renders the hint bar - **Ctrl+C exits immediately, no confirmation** — use `cmdutil.IsPromptCancel(err)` to detect either Esc or Ctrl+C and return cleanly. When a flow needs different behavior per key (e.g. a "Back to list / Exit" gate where Esc means back), split with `IsPromptInterrupt(err)` (Ctrl+C) and `IsPromptBack(err)` (Esc). Never show an "Exit?" confirmation dialog — Unix users expect Ctrl+C to be terminal -- **Never modify `verdagostack`** directly — describe needed changes for the maintainer +- **`pkg/` is in-tree** — the TUI core (`pkg/tui*`), `pkg/log`, `pkg/version` are part of this repo; edit them directly - **Commit only when asked** — don't auto-commit ## Risky Areas — Slow Down @@ -44,7 +44,6 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing | `options/credentials.go` | Break auth = break everything | Test all profiles, expired tokens | | Agent mode (`--agent`) | JSON contract change = break downstream | Check structured error format | | Wizard steps | Step ordering, cache invalidation | Map dependencies before coding | -| `verdagostack` types | Shared across repos | Don't modify, describe changes needed | ## Done Checklist @@ -57,47 +56,3 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing - [ ] No leftover debug code, TODOs, or commented-out blocks If `make lint` reports issues, fix them *before* announcing completion. See `CLAUDE.md` § "Go House Style" for the patterns that prevent the common hits (http.NoBody, American spelling, reused constants, rangeValCopy, nilerr annotations, etc.). - - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **verda-cli** (7546 symbols, 19146 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/verda-cli/context` | Codebase overview, check index freshness | -| `gitnexus://repo/verda-cli/clusters` | All functional areas | -| `gitnexus://repo/verda-cli/processes` | All execution flows | -| `gitnexus://repo/verda-cli/process/{name}` | Step-by-step execution trace | - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - - diff --git a/CLAUDE.md b/CLAUDE.md index bfc2ce2..0c8e57f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ internal/verda-cli/ README.md # Usage examples, flags, architecture notes options/ # Global CLI options, credentials internal/skills/ # Embedded AI skill files (go:embed) +pkg/ # In-tree TUI core, log, version (formerly verdagostack) ``` ### Per-Command Documentation @@ -56,13 +57,15 @@ Each command directory has its own `CLAUDE.md` (domain knowledge) and `README.md ### Core Patterns - **Factory** (`cmd/util/factory.go`): DI for Prompter, Status, VerdaClient, Debug, AgentMode, OutputFormat -- **Wizard engine** (`verdagostack/pkg/tui/wizard`): Multi-step interactive flows +- **Wizard engine** (`pkg/tui/wizard`): Multi-step interactive flows - **Lazy client** (`clientFunc`): API client resolved on first use, not at init - **API cache** (`apiCache`): Shared across wizard steps to avoid redundant calls -### Local Dependencies +### TUI / Log / Version packages (`pkg/`) -- `verdagostack` replaced locally: `replace github.com/verda-cloud/verdagostack => ../verdagostack` +- `pkg/tui` (+ `bubbletea`, `wizard`, `testing`), `pkg/log`, `pkg/version` live in-tree — + edit them directly like any other code in this repo (they were copied from + `verdagostack` v1.4.2, which this repo no longer depends on) - Bubble Tea v2 (`charm.land/bubbletea/v2`), lipgloss v2 (`charm.land/lipgloss/v2`) - Never use v1 imports — they won't compile @@ -169,47 +172,3 @@ If you modified a command, also verify: ## Other Agents This repo targets Claude Code and OpenAI Codex. Claude auto-loads this file; Codex auto-loads `AGENTS.md` (execution contract). A `.cursor/rules/main.mdc` pointer exists for Cursor users but is not a primary target — if Cursor drops out of the stack, delete it rather than letting it drift. - - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **verda-cli** (7546 symbols, 19146 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/verda-cli/context` | Codebase overview, check index freshness | -| `gitnexus://repo/verda-cli/clusters` | All functional areas | -| `gitnexus://repo/verda-cli/processes` | All execution flows | -| `gitnexus://repo/verda-cli/process/{name}` | Step-by-step execution trace | - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | - - diff --git a/go.mod b/go.mod index 7f305b4..647e12f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/verda-cloud/verda-cli -go 1.25.11 +go 1.25.12 require ( charm.land/lipgloss/v2 v2.0.2 @@ -9,11 +9,11 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/verda-cloud/verdacloud-sdk-go v1.4.2 - github.com/verda-cloud/verdagostack v1.4.2 go.yaml.in/yaml/v3 v3.0.4 ) require ( + charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.2 github.com/aws/aws-sdk-go-v2 v1.41.6 github.com/aws/aws-sdk-go-v2/config v1.32.16 @@ -24,11 +24,11 @@ require ( github.com/charmbracelet/x/term v0.2.2 github.com/google/go-containerregistry v0.21.5 github.com/mark3labs/mcp-go v0.47.0 + go.uber.org/zap v1.27.1 gopkg.in/ini.v1 v1.67.1 ) require ( - charm.land/bubbles/v2 v2.1.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect @@ -70,7 +70,7 @@ require ( github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -93,12 +93,13 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.27.1 // indirect - golang.org/x/sync v0.20.0 // indirect + golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/text v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index d03f89f..d7ed18e 100644 --- a/go.sum +++ b/go.sum @@ -121,8 +121,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -189,8 +189,6 @@ github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CP github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/verda-cloud/verdacloud-sdk-go v1.4.2 h1:oVb8fHVQOY+YPuuMYMee9gYCkPTwAw01LmkqxM21T/Y= github.com/verda-cloud/verdacloud-sdk-go v1.4.2/go.mod h1:pmlpiCL9fTSikZ3qWLJPpHOG0E8PKkQVUX5s4Z+SktY= -github.com/verda-cloud/verdagostack v1.4.2 h1:JiTBWB+WeFOSPoWO2dBVkP3NyV2tId8OEeJw9MNI32k= -github.com/verda-cloud/verdagostack v1.4.2/go.mod h1:TuJkNkis787dfJTU//dTKEMTbL/tDWDlgcPPI0WiJgw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -199,16 +197,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= @@ -219,16 +217,16 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/verda-cli/cmd/auth/CLAUDE.md b/internal/verda-cli/cmd/auth/CLAUDE.md index 5ee1b6a..a0b263d 100644 --- a/internal/verda-cli/cmd/auth/CLAUDE.md +++ b/internal/verda-cli/cmd/auth/CLAUDE.md @@ -32,7 +32,7 @@ ## Relationships - `cmdutil.Factory` / `cmdutil.IOStreams` -- standard dependency injection - `options` package -- `VerdaDir()`, `DefaultCredentialsFilePath()`, `LoadSharedCredentialsForProfile()`, `EnsureVerdaDir()`, `WriteSecureFile()` -- `verdagostack/pkg/tui/wizard` -- wizard engine and step definitions -- `verdagostack/pkg/tui/bubbletea` -- `HintStyle()` for wizard hint bar +- `pkg/tui/wizard` -- wizard engine and step definitions +- `pkg/tui/bubbletea` -- `HintStyle()` for wizard hint bar - `gopkg.in/ini.v1` -- INI file read/write for credentials - `go.yaml.in/yaml/v3` -- YAML read/write for config diff --git a/internal/verda-cli/cmd/auth/login.go b/internal/verda-cli/cmd/auth/login.go index aafc7ff..a6c029b 100644 --- a/internal/verda-cli/cmd/auth/login.go +++ b/internal/verda-cli/cmd/auth/login.go @@ -23,7 +23,7 @@ import ( "github.com/spf13/cobra" "gopkg.in/ini.v1" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" diff --git a/internal/verda-cli/cmd/auth/use.go b/internal/verda-cli/cmd/auth/use.go index d522668..0dfb934 100644 --- a/internal/verda-cli/cmd/auth/use.go +++ b/internal/verda-cli/cmd/auth/use.go @@ -19,7 +19,7 @@ import ( "os" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" "go.yaml.in/yaml/v3" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" diff --git a/internal/verda-cli/cmd/auth/wizard.go b/internal/verda-cli/cmd/auth/wizard.go index 537f607..d2f4360 100644 --- a/internal/verda-cli/cmd/auth/wizard.go +++ b/internal/verda-cli/cmd/auth/wizard.go @@ -18,8 +18,8 @@ import ( "errors" "strings" - "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" ) const ( diff --git a/internal/verda-cli/cmd/auth/wizard_test.go b/internal/verda-cli/cmd/auth/wizard_test.go index 6864a20..8222d92 100644 --- a/internal/verda-cli/cmd/auth/wizard_test.go +++ b/internal/verda-cli/cmd/auth/wizard_test.go @@ -19,7 +19,7 @@ import ( "io" "testing" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" ) func TestBuildLoginFlowHappyPath(t *testing.T) { diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index 448e826..a7b6df2 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -22,9 +22,9 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/verda-cloud/verdagostack/pkg/log" - "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" - "github.com/verda-cloud/verdagostack/pkg/version" + "github.com/verda-cloud/verda-cli/pkg/log" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" + "github.com/verda-cloud/verda-cli/pkg/version" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/auth" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/availability" @@ -261,9 +261,8 @@ var ErrVersionRequested = errors.New("version requested") func versionOutput() string { info := version.Get() sdkVer := depVersion("github.com/verda-cloud/verdacloud-sdk-go") - stackVer := depVersion("github.com/verda-cloud/verdagostack") - return fmt.Sprintf(" Version: %s\n Platform: %s\n SDK: %s\n Verdagostack: %s\n", - info.GitVersion, info.Platform, sdkVer, stackVer) + return fmt.Sprintf(" Version: %s\n Platform: %s\n SDK: %s\n", + info.GitVersion, info.Platform, sdkVer) } func depVersion(modulePath string) string { diff --git a/internal/verda-cli/cmd/mcp/server.go b/internal/verda-cli/cmd/mcp/server.go index 1324411..f723c38 100644 --- a/internal/verda-cli/cmd/mcp/server.go +++ b/internal/verda-cli/cmd/mcp/server.go @@ -22,8 +22,8 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + pkgversion "github.com/verda-cloud/verda-cli/pkg/version" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - pkgversion "github.com/verda-cloud/verdagostack/pkg/version" ) // clientFunc is a function that returns a Verda client on demand. diff --git a/internal/verda-cli/cmd/objectstorage/CLAUDE.md b/internal/verda-cli/cmd/objectstorage/CLAUDE.md index 0d193b5..cb06102 100644 --- a/internal/verda-cli/cmd/objectstorage/CLAUDE.md +++ b/internal/verda-cli/cmd/objectstorage/CLAUDE.md @@ -85,6 +85,6 @@ Do NOT escape the whole `bucket/key` as a single string -- S3 rejects a pre-esca - `cmdutil` (`internal/verda-cli/cmd/util`) -- Factory, IOStreams, `DebugJSON`, `WriteStructured`, `AgentError` helpers, `LongDesc`, `Examples` - `options` -- `S3Credentials`, `LoadS3CredentialsForProfile`, `DefaultCredentialsFilePath`, `EnsureVerdaDir` -- `verdagostack/pkg/tui/wizard` -- only imported by `configure.go` for the credential-setup wizard +- `pkg/tui/wizard` -- only imported by `configure.go` for the credential-setup wizard - AWS SDK v2 -- `aws`, `aws/signer/v4`, `config`, `credentials`, `feature/s3/manager`, `service/s3`, `service/s3/types`, `smithy-go` - `charm.land/lipgloss/v2` -- destructive-action warning styles in `rb.go`, `rm.go` diff --git a/internal/verda-cli/cmd/objectstorage/browse.go b/internal/verda-cli/cmd/objectstorage/browse.go index 67f298c..eb0f371 100644 --- a/internal/verda-cli/cmd/objectstorage/browse.go +++ b/internal/verda-cli/cmd/objectstorage/browse.go @@ -27,7 +27,7 @@ import ( "charm.land/lipgloss/v2" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/browse_test.go b/internal/verda-cli/cmd/objectstorage/browse_test.go index d699736..0bd8ac6 100644 --- a/internal/verda-cli/cmd/objectstorage/browse_test.go +++ b/internal/verda-cli/cmd/objectstorage/browse_test.go @@ -27,7 +27,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/configure.go b/internal/verda-cli/cmd/objectstorage/configure.go index 3e26267..ffa3edc 100644 --- a/internal/verda-cli/cmd/objectstorage/configure.go +++ b/internal/verda-cli/cmd/objectstorage/configure.go @@ -23,7 +23,7 @@ import ( "github.com/spf13/cobra" "gopkg.in/ini.v1" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" diff --git a/internal/verda-cli/cmd/objectstorage/move_wizard.go b/internal/verda-cli/cmd/objectstorage/move_wizard.go index cfc40b3..4e0a8e6 100644 --- a/internal/verda-cli/cmd/objectstorage/move_wizard.go +++ b/internal/verda-cli/cmd/objectstorage/move_wizard.go @@ -23,9 +23,9 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" - "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/picker.go b/internal/verda-cli/cmd/objectstorage/picker.go index c869c8a..562181b 100644 --- a/internal/verda-cli/cmd/objectstorage/picker.go +++ b/internal/verda-cli/cmd/objectstorage/picker.go @@ -22,7 +22,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/picker_test.go b/internal/verda-cli/cmd/objectstorage/picker_test.go index ba931e6..2b0ee37 100644 --- a/internal/verda-cli/cmd/objectstorage/picker_test.go +++ b/internal/verda-cli/cmd/objectstorage/picker_test.go @@ -22,7 +22,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/spf13/cobra" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/resume_uploads.go b/internal/verda-cli/cmd/objectstorage/resume_uploads.go index 1f98a3b..fe7ad69 100644 --- a/internal/verda-cli/cmd/objectstorage/resume_uploads.go +++ b/internal/verda-cli/cmd/objectstorage/resume_uploads.go @@ -24,7 +24,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/rm_browse.go b/internal/verda-cli/cmd/objectstorage/rm_browse.go index 5437496..fcf77a6 100644 --- a/internal/verda-cli/cmd/objectstorage/rm_browse.go +++ b/internal/verda-cli/cmd/objectstorage/rm_browse.go @@ -18,7 +18,7 @@ import ( "context" "fmt" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/rm_test.go b/internal/verda-cli/cmd/objectstorage/rm_test.go index baa24a2..7020acd 100644 --- a/internal/verda-cli/cmd/objectstorage/rm_test.go +++ b/internal/verda-cli/cmd/objectstorage/rm_test.go @@ -25,7 +25,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/tui_interactive_test.go b/internal/verda-cli/cmd/objectstorage/tui_interactive_test.go index 3704a45..b88ff70 100644 --- a/internal/verda-cli/cmd/objectstorage/tui_interactive_test.go +++ b/internal/verda-cli/cmd/objectstorage/tui_interactive_test.go @@ -24,8 +24,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/upload_wizard.go b/internal/verda-cli/cmd/objectstorage/upload_wizard.go index 06b5858..00c5210 100644 --- a/internal/verda-cli/cmd/objectstorage/upload_wizard.go +++ b/internal/verda-cli/cmd/objectstorage/upload_wizard.go @@ -24,7 +24,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/upload_wizard_test.go b/internal/verda-cli/cmd/objectstorage/upload_wizard_test.go index 2aa12b8..51aea1d 100644 --- a/internal/verda-cli/cmd/objectstorage/upload_wizard_test.go +++ b/internal/verda-cli/cmd/objectstorage/upload_wizard_test.go @@ -25,8 +25,8 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + "github.com/verda-cloud/verda-cli/pkg/tui" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/uploads_test.go b/internal/verda-cli/cmd/objectstorage/uploads_test.go index 9e8a7e6..8b47813 100644 --- a/internal/verda-cli/cmd/objectstorage/uploads_test.go +++ b/internal/verda-cli/cmd/objectstorage/uploads_test.go @@ -27,7 +27,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/spf13/cobra" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/objectstorage/wizard.go b/internal/verda-cli/cmd/objectstorage/wizard.go index 29debee..3106da0 100644 --- a/internal/verda-cli/cmd/objectstorage/wizard.go +++ b/internal/verda-cli/cmd/objectstorage/wizard.go @@ -19,9 +19,9 @@ import ( "errors" "strings" - "github.com/verda-cloud/verdagostack/pkg/tui" - "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" ) diff --git a/internal/verda-cli/cmd/objectstorage/wizard_test.go b/internal/verda-cli/cmd/objectstorage/wizard_test.go index a396c24..9158d01 100644 --- a/internal/verda-cli/cmd/objectstorage/wizard_test.go +++ b/internal/verda-cli/cmd/objectstorage/wizard_test.go @@ -20,7 +20,7 @@ import ( "path/filepath" "testing" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" ) func TestBuildConfigureFlowHappyPath(t *testing.T) { diff --git a/internal/verda-cli/cmd/registry/CLAUDE.md b/internal/verda-cli/cmd/registry/CLAUDE.md index 4875eaa..50fe124 100644 --- a/internal/verda-cli/cmd/registry/CLAUDE.md +++ b/internal/verda-cli/cmd/registry/CLAUDE.md @@ -164,8 +164,8 @@ Before each `Write`, we `Head` the destination ref: - `cmdutil` (`internal/verda-cli/cmd/util`) -- `Factory`, `IOStreams`, `DebugJSON`, `WriteStructured`, `AgentError`, `NewConfirmationRequiredError`, `LongDesc`, `Examples`, `UsageErrorf`, exit-code constants. - `options` -- `RegistryCredentials`, `LoadRegistryCredentialsForProfile`, `WriteRegistryCredentialsToProfile`, `DefaultCredentialsFilePath`, `EnsureVerdaDir`. -- `verdagostack/pkg/tui/wizard` -- imported by `configure.go` only (the credential-setup wizard). -- `verdagostack/pkg/tui` -- `WithConfirmDefault` for the overwrite prompt in `copy`. +- `pkg/tui/wizard` -- imported by `configure.go` only (the credential-setup wizard). +- `pkg/tui` -- `WithConfirmDefault` for the overwrite prompt in `copy`. - `google/go-containerregistry` -- `pkg/v1`, `pkg/v1/remote`, `pkg/v1/remote/transport`, `pkg/v1/daemon`, `pkg/v1/layout`, `pkg/v1/tarball`, `pkg/name`, `pkg/authn` (plus `pkg/registry` as the in-process test server in `*_test.go`). - `charm.land/bubbletea/v2`, `charm.land/lipgloss/v2` -- progress view, picker, and wizard styling. - `github.com/charmbracelet/x/term` -- `IsTerminal` wired through `isTerminalFn`. @@ -258,7 +258,7 @@ Harbor returns HTTP 412 when a project policy — **Tag Immutability** or **Tag 1. Outer picker: select a repository (or Exit). 2. Inner menu: "Delete image(s) from X", "Delete repository X (all images)", "Back to repository list", "Exit". -3. The image-delete sub-flow uses `prompter.MultiSelect` with a label that surfaces the **Ctrl+A** "select all" keystroke — the bubbletea `MultiSelect` already supports it natively (see `verdagostack/pkg/tui/bubbletea/multiselect.go`), so we just advertise it in the prompt label and tests emulate it via `AddMultiSelect([]int{0, 1, ..., n-1})`. +3. The image-delete sub-flow uses `prompter.MultiSelect` with a label that surfaces the **Ctrl+A** "select all" keystroke — the bubbletea `MultiSelect` already supports it natively (see `pkg/tui/bubbletea/multiselect.go`), so we just advertise it in the prompt label and tests emulate it via `AddMultiSelect([]int{0, 1, ..., n-1})`. 4. The image batch runs sequentially (Harbor has no bulk-delete endpoint). Failures on individual artifacts are collected and reported at the end; survivors still get deleted — users generally want partial progress, not all-or-nothing. 5. Error handling in the outer loop classifies via `isAccessDenied` (shared with `ls`): a `registry_access_denied` (Harbor 403) is a permission wall — surface the actionable error once and **return** (exit), rather than looping the user back into a picker that can only re-fail. This is the common case for credentials minted before the Harbor image permission was granted (the error text tells them to re-`configure` with a fresh credential). Transient (5xx) errors still print + `continue` so one flaky fetch doesn't eject the user. diff --git a/internal/verda-cli/cmd/registry/README.md b/internal/verda-cli/cmd/registry/README.md index 137e22c..24212a8 100644 --- a/internal/verda-cli/cmd/registry/README.md +++ b/internal/verda-cli/cmd/registry/README.md @@ -552,7 +552,7 @@ Business logic highlights: - **Interactive `ls` drill-down**: on a TTY, `ls` routes through `f.Prompter().Select` to let the user pick a repository, then calls `RepositoryLister.ListArtifacts` and renders a per-artifact card (digest / tags / size / push / pull). Non-TTY output is always the flat repo table; structured output (`-o json|yaml`) never enters the picker. TTY detection is swappable via `isTerminalFn` (shared with `copy` / `push_view.go`), which also lets tests exercise the picker path without a real terminal. - **Delete target classification**: `classifyTarget` inspects the raw positional argument's last path segment for `@` or `:` to distinguish bare repositories from artifact references, *before* calling `Normalize`. `Normalize` defaults the tag to `"latest"` for push/copy semantics — reusing that default for delete would silently convert `delete library/hello-world` into `delete library/hello-world:latest`, which is the wrong intent. Cross-project targets are rejected locally with `registry_invalid_reference` so the user gets a useful message instead of a 403 from Harbor. - **Policy-blocked deletes (HTTP 412)**: Harbor returns 412 when a project Tag Immutability / Tag Retention rule forbids the operation. `translateHarborError` maps that to `registry_delete_blocked` with a recovery message walking the user through editing the policy in the web UI or escalating to support; the Harbor response body (usually `"matched rule X"`) is folded into the message verbatim. -- **Interactive delete flow**: on a TTY with no positional arg, `delete` drives a two-level menu — outer picker over repositories (same `formatRepoRow` as `ls`), inner menu per repo (delete image(s) / delete repository / back / exit). The image-delete step uses `prompter.MultiSelect`, which natively supports Ctrl+A "select all" (see `verdagostack/pkg/tui/bubbletea/multiselect.go`); the prompt label advertises the keystroke. Batches run sequentially with partial-success reporting — one failing artifact never cancels siblings. +- **Interactive delete flow**: on a TTY with no positional arg, `delete` drives a two-level menu — outer picker over repositories (same `formatRepoRow` as `ls`), inner menu per repo (delete image(s) / delete repository / back / exit). The image-delete step uses `prompter.MultiSelect`, which natively supports Ctrl+A "select all" (see `pkg/tui/bubbletea/multiselect.go`); the prompt label advertises the keystroke. Batches run sequentially with partial-success reporting — one failing artifact never cancels siblings. Wizard flow (`configure`): diff --git a/internal/verda-cli/cmd/registry/configure.go b/internal/verda-cli/cmd/registry/configure.go index dccb82a..11fff29 100644 --- a/internal/verda-cli/cmd/registry/configure.go +++ b/internal/verda-cli/cmd/registry/configure.go @@ -24,7 +24,7 @@ import ( "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" diff --git a/internal/verda-cli/cmd/registry/configure_test.go b/internal/verda-cli/cmd/registry/configure_test.go index 35e3d3d..41c630f 100644 --- a/internal/verda-cli/cmd/registry/configure_test.go +++ b/internal/verda-cli/cmd/registry/configure_test.go @@ -26,7 +26,7 @@ import ( "gopkg.in/ini.v1" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" ) // newTestStreams returns IOStreams backed by buffers, with `stdin` providing diff --git a/internal/verda-cli/cmd/registry/copy.go b/internal/verda-cli/cmd/registry/copy.go index ba8d3eb..ee6b38b 100644 --- a/internal/verda-cli/cmd/registry/copy.go +++ b/internal/verda-cli/cmd/registry/copy.go @@ -27,7 +27,7 @@ import ( "github.com/google/go-containerregistry/pkg/authn" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" diff --git a/internal/verda-cli/cmd/registry/copy_test.go b/internal/verda-cli/cmd/registry/copy_test.go index 16e9ca0..f23c657 100644 --- a/internal/verda-cli/cmd/registry/copy_test.go +++ b/internal/verda-cli/cmd/registry/copy_test.go @@ -34,7 +34,7 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/random" "github.com/google/go-containerregistry/pkg/v1/remote/transport" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" diff --git a/internal/verda-cli/cmd/registry/copy_wizard.go b/internal/verda-cli/cmd/registry/copy_wizard.go index e4c7f5c..34a88bb 100644 --- a/internal/verda-cli/cmd/registry/copy_wizard.go +++ b/internal/verda-cli/cmd/registry/copy_wizard.go @@ -20,7 +20,7 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" diff --git a/internal/verda-cli/cmd/registry/copy_wizard_test.go b/internal/verda-cli/cmd/registry/copy_wizard_test.go index f2665ce..fd27f88 100644 --- a/internal/verda-cli/cmd/registry/copy_wizard_test.go +++ b/internal/verda-cli/cmd/registry/copy_wizard_test.go @@ -25,7 +25,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" ) // readCaptureRegistry records the ref passed to Read, then errors so the copy diff --git a/internal/verda-cli/cmd/registry/delete.go b/internal/verda-cli/cmd/registry/delete.go index 48692bc..a3798e0 100644 --- a/internal/verda-cli/cmd/registry/delete.go +++ b/internal/verda-cli/cmd/registry/delete.go @@ -21,7 +21,7 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" diff --git a/internal/verda-cli/cmd/registry/delete_test.go b/internal/verda-cli/cmd/registry/delete_test.go index d02e423..0b6e8c8 100644 --- a/internal/verda-cli/cmd/registry/delete_test.go +++ b/internal/verda-cli/cmd/registry/delete_test.go @@ -22,7 +22,7 @@ import ( "testing" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" ) // runDeleteForTest exercises the real flag-parsing path so test argv diff --git a/internal/verda-cli/cmd/registry/ls.go b/internal/verda-cli/cmd/registry/ls.go index 86ba256..8cf735b 100644 --- a/internal/verda-cli/cmd/registry/ls.go +++ b/internal/verda-cli/cmd/registry/ls.go @@ -22,7 +22,7 @@ import ( "strconv" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" diff --git a/internal/verda-cli/cmd/registry/ls_test.go b/internal/verda-cli/cmd/registry/ls_test.go index 026011e..bf8f519 100644 --- a/internal/verda-cli/cmd/registry/ls_test.go +++ b/internal/verda-cli/cmd/registry/ls_test.go @@ -27,7 +27,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" ) // withFakeRegistryTags swaps clientBuilder so the ggcr Registry's Tags returns diff --git a/internal/verda-cli/cmd/registry/push.go b/internal/verda-cli/cmd/registry/push.go index 4825271..3d1996b 100644 --- a/internal/verda-cli/cmd/registry/push.go +++ b/internal/verda-cli/cmd/registry/push.go @@ -26,7 +26,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" v1 "github.com/google/go-containerregistry/pkg/v1" diff --git a/internal/verda-cli/cmd/registry/push_test.go b/internal/verda-cli/cmd/registry/push_test.go index 371947e..81d2571 100644 --- a/internal/verda-cli/cmd/registry/push_test.go +++ b/internal/verda-cli/cmd/registry/push_test.go @@ -32,7 +32,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" ) // ---------- push test helpers ---------- diff --git a/internal/verda-cli/cmd/registry/tags_test.go b/internal/verda-cli/cmd/registry/tags_test.go index b76da7d..5f43b28 100644 --- a/internal/verda-cli/cmd/registry/tags_test.go +++ b/internal/verda-cli/cmd/registry/tags_test.go @@ -26,7 +26,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" ) // runTagsForTest exercises the real flag-parsing path so tests match diff --git a/internal/verda-cli/cmd/registry/wizard.go b/internal/verda-cli/cmd/registry/wizard.go index dc8adc6..99878c6 100644 --- a/internal/verda-cli/cmd/registry/wizard.go +++ b/internal/verda-cli/cmd/registry/wizard.go @@ -21,9 +21,9 @@ import ( "strconv" "strings" - "github.com/verda-cloud/verdagostack/pkg/tui" - "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" ) diff --git a/internal/verda-cli/cmd/registry/wizard_test.go b/internal/verda-cli/cmd/registry/wizard_test.go index ef4eff0..800fd39 100644 --- a/internal/verda-cli/cmd/registry/wizard_test.go +++ b/internal/verda-cli/cmd/registry/wizard_test.go @@ -21,7 +21,7 @@ import ( "strings" "testing" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" ) func TestBuildConfigureFlow_Structure(t *testing.T) { diff --git a/internal/verda-cli/cmd/serverless/CLAUDE.md b/internal/verda-cli/cmd/serverless/CLAUDE.md index a9f2439..5c716ff 100644 --- a/internal/verda-cli/cmd/serverless/CLAUDE.md +++ b/internal/verda-cli/cmd/serverless/CLAUDE.md @@ -133,8 +133,8 @@ Describe cards (`renderContainerDeploymentCard`, `renderJobDeploymentCard`) prin ## Relationships - `cmdutil` (`internal/verda-cli/cmd/util`) — `Factory`, `IOStreams`, `WithSpinner`, `RunWithSpinner`, `DebugJSON`, `WriteStructured`, `NewMissingFlagsError`, `NewConfirmationRequiredError`, `UsageErrorf`, `LongDesc`, `Examples`, `DefaultSubCommandRun`. -- `verdagostack/pkg/tui/wizard` — `Flow`, `Step`, `Choice`, `Store`, `Engine`, `NewEngine`, `StaticChoices`, `WithOutput`, `WithExitConfirmation`, prompt-type enums. -- `verdagostack/pkg/tui` — `Prompter`, `Status`, `WithConfirmDefault`. +- `pkg/tui/wizard` — `Flow`, `Step`, `Choice`, `Store`, `Engine`, `NewEngine`, `StaticChoices`, `WithOutput`, `WithExitConfirmation`, prompt-type enums. +- `pkg/tui` — `Prompter`, `Status`, `WithConfirmDefault`. - SDK (`verdacloud-sdk-go/pkg/verda`): - `ContainerDeploymentsService` — `GetDeployments`, `CreateDeployment`, `GetDeploymentByName`, `DeleteDeployment`, `GetDeploymentStatus`, `PauseDeployment`, `ResumeDeployment`, `RestartDeployment`, `PurgeDeploymentQueue`, `GetServerlessComputeResources`, `GetRegistryCredentials`, `GetSecrets`, `GetFileSecrets`, `ValidateCreateDeploymentRequest`. - `ServerlessJobsService` — `GetJobDeployments`, `CreateJobDeployment`, `GetJobDeploymentByName`, `DeleteJobDeployment`, `GetJobDeploymentStatus`, `PauseJobDeployment`, `ResumeJobDeployment`, `PurgeJobDeploymentQueue`, `ValidateCreateJobDeploymentRequest`. diff --git a/internal/verda-cli/cmd/serverless/batchjob_create.go b/internal/verda-cli/cmd/serverless/batchjob_create.go index 423dbb3..d5887a0 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_create.go +++ b/internal/verda-cli/cmd/serverless/batchjob_create.go @@ -21,8 +21,8 @@ import ( "time" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/serverless/batchjob_describe.go b/internal/verda-cli/cmd/serverless/batchjob_describe.go index ba09f5a..682ef6c 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_describe.go +++ b/internal/verda-cli/cmd/serverless/batchjob_describe.go @@ -22,8 +22,8 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/serverless/container_create.go b/internal/verda-cli/cmd/serverless/container_create.go index a137fb3..266ed30 100644 --- a/internal/verda-cli/cmd/serverless/container_create.go +++ b/internal/verda-cli/cmd/serverless/container_create.go @@ -22,8 +22,8 @@ import ( "time" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/serverless/container_describe.go b/internal/verda-cli/cmd/serverless/container_describe.go index 372c29e..36fb75f 100644 --- a/internal/verda-cli/cmd/serverless/container_describe.go +++ b/internal/verda-cli/cmd/serverless/container_describe.go @@ -22,8 +22,8 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/serverless/container_list.go b/internal/verda-cli/cmd/serverless/container_list.go index bd595e9..2cd9f53 100644 --- a/internal/verda-cli/cmd/serverless/container_list.go +++ b/internal/verda-cli/cmd/serverless/container_list.go @@ -23,8 +23,8 @@ import ( "text/tabwriter" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/serverless/shared.go b/internal/verda-cli/cmd/serverless/shared.go index 86b0382..df91644 100644 --- a/internal/verda-cli/cmd/serverless/shared.go +++ b/internal/verda-cli/cmd/serverless/shared.go @@ -22,8 +22,8 @@ import ( "strings" "charm.land/lipgloss/v2" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/serverless/wizard.go b/internal/verda-cli/cmd/serverless/wizard.go index 6ec4d83..6381077 100644 --- a/internal/verda-cli/cmd/serverless/wizard.go +++ b/internal/verda-cli/cmd/serverless/wizard.go @@ -21,7 +21,7 @@ import ( "strings" "time" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" ) // Container-specific wizard steps. Steps shared with batchjob live in diff --git a/internal/verda-cli/cmd/serverless/wizard_batchjob.go b/internal/verda-cli/cmd/serverless/wizard_batchjob.go index 90e95bb..663363c 100644 --- a/internal/verda-cli/cmd/serverless/wizard_batchjob.go +++ b/internal/verda-cli/cmd/serverless/wizard_batchjob.go @@ -20,7 +20,7 @@ import ( "strings" "time" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" ) // buildBatchjobCreateFlow returns the wizard flow for `verda batchjob create`. diff --git a/internal/verda-cli/cmd/serverless/wizard_cache.go b/internal/verda-cli/cmd/serverless/wizard_cache.go index 006deaf..2626db9 100644 --- a/internal/verda-cli/cmd/serverless/wizard_cache.go +++ b/internal/verda-cli/cmd/serverless/wizard_cache.go @@ -18,8 +18,8 @@ import ( "context" "fmt" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" ) // withFetchSpinner runs fn while showing a spinner labeled msg. If status is diff --git a/internal/verda-cli/cmd/serverless/wizard_shared.go b/internal/verda-cli/cmd/serverless/wizard_shared.go index 5419b82..40c1e2b 100644 --- a/internal/verda-cli/cmd/serverless/wizard_shared.go +++ b/internal/verda-cli/cmd/serverless/wizard_shared.go @@ -22,9 +22,9 @@ import ( "strings" "time" + "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) // This file holds step builders and helpers shared between the container and diff --git a/internal/verda-cli/cmd/serverless/wizard_subflows.go b/internal/verda-cli/cmd/serverless/wizard_subflows.go index 2885301..443e7ac 100644 --- a/internal/verda-cli/cmd/serverless/wizard_subflows.go +++ b/internal/verda-cli/cmd/serverless/wizard_subflows.go @@ -18,8 +18,8 @@ import ( "context" "strings" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" ) // promptEnvVar collects one environment-variable entry interactively. Returns diff --git a/internal/verda-cli/cmd/settings/CLAUDE.md b/internal/verda-cli/cmd/settings/CLAUDE.md index d7badf8..cfec8ed 100644 --- a/internal/verda-cli/cmd/settings/CLAUDE.md +++ b/internal/verda-cli/cmd/settings/CLAUDE.md @@ -24,6 +24,6 @@ ## Relationships - `cmdutil.Factory` / `cmdutil.IOStreams` -- standard dependency injection - `options` package -- `SaveSetting()` for persisting to config YAML -- `verdagostack/pkg/tui/wizard` -- wizard engine, `SelectPrompt`, `StaticChoices`, `NewHintBarView` -- `verdagostack/pkg/tui/bubbletea` -- `Themes`, `ThemeNames()`, `GetThemeName()`, `SetThemeByName()`, `HintStyle()`, `Theme` type +- `pkg/tui/wizard` -- wizard engine, `SelectPrompt`, `StaticChoices`, `NewHintBarView` +- `pkg/tui/bubbletea` -- `Themes`, `ThemeNames()`, `GetThemeName()`, `SetThemeByName()`, `HintStyle()`, `Theme` type - `charm.land/lipgloss/v2` -- used in `renderThemePreview` for styled color swatches diff --git a/internal/verda-cli/cmd/settings/theme.go b/internal/verda-cli/cmd/settings/theme.go index 3a9568f..b28972f 100644 --- a/internal/verda-cli/cmd/settings/theme.go +++ b/internal/verda-cli/cmd/settings/theme.go @@ -20,8 +20,8 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" diff --git a/internal/verda-cli/cmd/skills/install_test.go b/internal/verda-cli/cmd/skills/install_test.go index c272ab4..5d9ee15 100644 --- a/internal/verda-cli/cmd/skills/install_test.go +++ b/internal/verda-cli/cmd/skills/install_test.go @@ -21,7 +21,7 @@ import ( "path/filepath" "testing" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/skills/status_test.go b/internal/verda-cli/cmd/skills/status_test.go index e4b1ba1..ae6a218 100644 --- a/internal/verda-cli/cmd/skills/status_test.go +++ b/internal/verda-cli/cmd/skills/status_test.go @@ -23,7 +23,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" ) func TestRunStatus_Installed(t *testing.T) { diff --git a/internal/verda-cli/cmd/skills/uninstall_test.go b/internal/verda-cli/cmd/skills/uninstall_test.go index 0b3cc5c..8095e39 100644 --- a/internal/verda-cli/cmd/skills/uninstall_test.go +++ b/internal/verda-cli/cmd/skills/uninstall_test.go @@ -24,7 +24,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" ) func TestUninstallCopy(t *testing.T) { diff --git a/internal/verda-cli/cmd/ssh/ssh.go b/internal/verda-cli/cmd/ssh/ssh.go index 4ded54f..d92bd6d 100644 --- a/internal/verda-cli/cmd/ssh/ssh.go +++ b/internal/verda-cli/cmd/ssh/ssh.go @@ -24,8 +24,8 @@ import ( "syscall" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/sshkey/delete.go b/internal/verda-cli/cmd/sshkey/delete.go index dc05eda..ba1cc2c 100644 --- a/internal/verda-cli/cmd/sshkey/delete.go +++ b/internal/verda-cli/cmd/sshkey/delete.go @@ -19,7 +19,7 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/startupscript/CLAUDE.md b/internal/verda-cli/cmd/startupscript/CLAUDE.md index 2791095..7e5831a 100644 --- a/internal/verda-cli/cmd/startupscript/CLAUDE.md +++ b/internal/verda-cli/cmd/startupscript/CLAUDE.md @@ -21,7 +21,7 @@ - In `add`, prompter errors return `nil` (not the error) -- intentional for Ctrl+C cancellation - Same cancellation pattern in `delete` - `add` imports `os` for `ReadFile` and `strings` for `TrimSpace` -- the only command in this package that reads files from disk -- `add` imports `github.com/verda-cloud/verdagostack/pkg/tui` for `tui.WithEditorDefault` and `tui.WithFileExt` editor options +- `add` imports `github.com/verda-cloud/verda-cli/pkg/tui` for `tui.WithEditorDefault` and `tui.WithFileExt` editor options - `delete` interactive mode uses two separate timeout contexts: one for listing, another for deleting - When no scripts exist, both `list` and `delete` print a friendly message and return `nil` @@ -29,5 +29,5 @@ - Depends on `cmdutil.Factory` for VerdaClient, Prompter, Status, Debug, Options - Depends on `cmdutil.IOStreams` for output routing - SDK dependency: `github.com/verda-cloud/verdacloud-sdk-go/pkg/verda` (in `add.go` for `CreateStartupScriptRequest`) -- TUI dependency: `github.com/verda-cloud/verdagostack/pkg/tui` (in `add.go` for editor options) +- TUI dependency: `github.com/verda-cloud/verda-cli/pkg/tui` (in `add.go` for editor options) - Uses `cmdutil.LongDesc`, `cmdutil.Examples`, `cmdutil.DebugJSON`, `cmdutil.DefaultSubCommandRun` diff --git a/internal/verda-cli/cmd/startupscript/add.go b/internal/verda-cli/cmd/startupscript/add.go index 5ba27f0..c7d7674 100644 --- a/internal/verda-cli/cmd/startupscript/add.go +++ b/internal/verda-cli/cmd/startupscript/add.go @@ -22,8 +22,8 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/startupscript/delete.go b/internal/verda-cli/cmd/startupscript/delete.go index bf5158f..f9c6a54 100644 --- a/internal/verda-cli/cmd/startupscript/delete.go +++ b/internal/verda-cli/cmd/startupscript/delete.go @@ -19,7 +19,7 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/template/create.go b/internal/verda-cli/cmd/template/create.go index f3aa2aa..7487334 100644 --- a/internal/verda-cli/cmd/template/create.go +++ b/internal/verda-cli/cmd/template/create.go @@ -20,7 +20,7 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/vm" diff --git a/internal/verda-cli/cmd/template/edit.go b/internal/verda-cli/cmd/template/edit.go index 7c16c95..a6fa473 100644 --- a/internal/verda-cli/cmd/template/edit.go +++ b/internal/verda-cli/cmd/template/edit.go @@ -22,8 +22,8 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/template/show.go b/internal/verda-cli/cmd/template/show.go index f6e5181..1706c2b 100644 --- a/internal/verda-cli/cmd/template/show.go +++ b/internal/verda-cli/cmd/template/show.go @@ -21,7 +21,7 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/update/CLAUDE.md b/internal/verda-cli/cmd/update/CLAUDE.md index 364490d..473a424 100644 --- a/internal/verda-cli/cmd/update/CLAUDE.md +++ b/internal/verda-cli/cmd/update/CLAUDE.md @@ -9,7 +9,7 @@ ## Domain-Specific Logic ### Version Resolution -- Current version from `version.Get().GitVersion` (from `verdagostack/pkg/version`) +- Current version from `version.Get().GitVersion` (from `pkg/version`) - Auto-prepends `v` prefix if missing from `--target` flag - Skips update if target == current @@ -33,7 +33,7 @@ ## Relationships - Imports `cmdutil` (`internal/verda-cli/cmd/util`) for Factory, IOStreams, DebugJSON, LongDesc, Examples -- Imports `version` from `verdagostack/pkg/version` for current version info +- Imports `version` from `pkg/version` for current version info - Does NOT use the Verda API client -- only GitHub API via raw HTTP - No dependency on the Verda SDK (`verdacloud-sdk-go`) at all - Uses standard library only for HTTP, archive handling, and file operations diff --git a/internal/verda-cli/cmd/update/update.go b/internal/verda-cli/cmd/update/update.go index 91fbbb0..8972a1d 100644 --- a/internal/verda-cli/cmd/update/update.go +++ b/internal/verda-cli/cmd/update/update.go @@ -33,7 +33,7 @@ import ( "time" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/version" + "github.com/verda-cloud/verda-cli/pkg/version" skillscmd "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/skills" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" diff --git a/internal/verda-cli/cmd/util/agent_prompter.go b/internal/verda-cli/cmd/util/agent_prompter.go index ced7d3e..a47f95d 100644 --- a/internal/verda-cli/cmd/util/agent_prompter.go +++ b/internal/verda-cli/cmd/util/agent_prompter.go @@ -17,7 +17,7 @@ package util import ( "context" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" ) // agentPrompter implements tui.Prompter but returns structured errors for every diff --git a/internal/verda-cli/cmd/util/factory.go b/internal/verda-cli/cmd/util/factory.go index 45083d7..f6b55a3 100644 --- a/internal/verda-cli/cmd/util/factory.go +++ b/internal/verda-cli/cmd/util/factory.go @@ -25,10 +25,10 @@ import ( "strings" "time" + "github.com/verda-cloud/verda-cli/pkg/tui" + _ "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" // registers bubbletea TUI backend + "github.com/verda-cloud/verda-cli/pkg/version" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" - _ "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" // registers bubbletea TUI backend - "github.com/verda-cloud/verdagostack/pkg/version" clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" ) diff --git a/internal/verda-cli/cmd/util/helpers.go b/internal/verda-cli/cmd/util/helpers.go index b7240f8..60a2ebb 100644 --- a/internal/verda-cli/cmd/util/helpers.go +++ b/internal/verda-cli/cmd/util/helpers.go @@ -24,7 +24,7 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" ) // IsPromptCancel reports whether err represents a clean prompter exit diff --git a/internal/verda-cli/cmd/util/spinner.go b/internal/verda-cli/cmd/util/spinner.go index 43cbe05..62e1b31 100644 --- a/internal/verda-cli/cmd/util/spinner.go +++ b/internal/verda-cli/cmd/util/spinner.go @@ -17,7 +17,7 @@ package util import ( "context" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" ) // WithSpinner runs fn while showing a spinner message. If status is nil or the diff --git a/internal/verda-cli/cmd/util/testing.go b/internal/verda-cli/cmd/util/testing.go index 46d90ff..55e493b 100644 --- a/internal/verda-cli/cmd/util/testing.go +++ b/internal/verda-cli/cmd/util/testing.go @@ -19,9 +19,9 @@ import ( "net/http" "time" + "github.com/verda-cloud/verda-cli/pkg/tui" + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" ) diff --git a/internal/verda-cli/cmd/util/versionhint.go b/internal/verda-cli/cmd/util/versionhint.go index 6a4638c..ca89a30 100644 --- a/internal/verda-cli/cmd/util/versionhint.go +++ b/internal/verda-cli/cmd/util/versionhint.go @@ -28,7 +28,7 @@ import ( "time" clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" - "github.com/verda-cloud/verdagostack/pkg/version" + "github.com/verda-cloud/verda-cli/pkg/version" ) // VersionCache holds the result of the last version check so we can avoid diff --git a/internal/verda-cli/cmd/vm/CLAUDE.md b/internal/verda-cli/cmd/vm/CLAUDE.md index 22acc53..c1e7cc8 100644 --- a/internal/verda-cli/cmd/vm/CLAUDE.md +++ b/internal/verda-cli/cmd/vm/CLAUDE.md @@ -119,9 +119,9 @@ startup-script -> hostname -> description -> confirm-deploy ## Relationships -- **wizard engine**: `verdagostack/pkg/tui/wizard` -- provides `Flow`, `Step`, `Store`, `Engine`, `Choice`, prompt types -- **tui package**: `verdagostack/pkg/tui` -- `Prompter`, `Status` interfaces, `WithDefault`, `WithConfirmDefault`, `WithEditorDefault`, `WithFileExt`, `WithMultiSelectDefaults` options -- **bubbletea package**: `verdagostack/pkg/tui/bubbletea` -- `HintStyle()` for wizard hints +- **wizard engine**: `pkg/tui/wizard` -- provides `Flow`, `Step`, `Store`, `Engine`, `Choice`, prompt types +- **tui package**: `pkg/tui` -- `Prompter`, `Status` interfaces, `WithDefault`, `WithConfirmDefault`, `WithEditorDefault`, `WithFileExt`, `WithMultiSelectDefaults` options +- **bubbletea package**: `pkg/tui/bubbletea` -- `HintStyle()` for wizard hints - **SDK**: `verdacloud-sdk-go/pkg/verda` -- all API client types, constants (`LocationFIN01`, `VolumeTypeNVMe`, `VolumeTypeHDD`, `SpotDiscontinue*`, `Status*`) - **cmdutil**: `cmd/util` -- `Factory`, `IOStreams`, `WithSpinner`, `RunWithSpinner`, `TemplatesBaseDir`, `DebugJSON`, `UsageErrorf`, `ValidateHostname`, `GenerateHostname`, `LongDesc`, `Examples`, `DefaultSubCommandRun` - **Factory dependencies**: `f.VerdaClient()`, `f.Prompter()`, `f.Status()`, `f.Debug()`, `f.Options().Timeout`, `f.OutputFormat()`, `f.AgentMode()` diff --git a/internal/verda-cli/cmd/vm/action.go b/internal/verda-cli/cmd/vm/action.go index b592679..242678e 100644 --- a/internal/verda-cli/cmd/vm/action.go +++ b/internal/verda-cli/cmd/vm/action.go @@ -21,8 +21,8 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/vm/create.go b/internal/verda-cli/cmd/vm/create.go index 38dc8ba..c19410a 100644 --- a/internal/verda-cli/cmd/vm/create.go +++ b/internal/verda-cli/cmd/vm/create.go @@ -25,8 +25,8 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) var validSpotPolicies = map[string]struct{}{ diff --git a/internal/verda-cli/cmd/vm/list.go b/internal/verda-cli/cmd/vm/list.go index d855187..5b132e4 100644 --- a/internal/verda-cli/cmd/vm/list.go +++ b/internal/verda-cli/cmd/vm/list.go @@ -21,8 +21,8 @@ import ( "sync" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/vm/template_apply.go b/internal/verda-cli/cmd/vm/template_apply.go index 1684eab..78290ae 100644 --- a/internal/verda-cli/cmd/vm/template_apply.go +++ b/internal/verda-cli/cmd/vm/template_apply.go @@ -21,8 +21,8 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/template" diff --git a/internal/verda-cli/cmd/vm/wizard.go b/internal/verda-cli/cmd/vm/wizard.go index e654fd2..ed981e5 100644 --- a/internal/verda-cli/cmd/vm/wizard.go +++ b/internal/verda-cli/cmd/vm/wizard.go @@ -21,10 +21,10 @@ import ( "strconv" "strings" + "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" - "github.com/verda-cloud/verdagostack/pkg/tui/bubbletea" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/vm/wizard_cache.go b/internal/verda-cli/cmd/vm/wizard_cache.go index 7fab24a..ebff57c 100644 --- a/internal/verda-cli/cmd/vm/wizard_cache.go +++ b/internal/verda-cli/cmd/vm/wizard_cache.go @@ -21,8 +21,8 @@ import ( "slices" "strings" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) // apiCache holds data fetched from the API, shared across wizard steps diff --git a/internal/verda-cli/cmd/vm/wizard_subflows.go b/internal/verda-cli/cmd/vm/wizard_subflows.go index 8916fab..28ba15e 100644 --- a/internal/verda-cli/cmd/vm/wizard_subflows.go +++ b/internal/verda-cli/cmd/vm/wizard_subflows.go @@ -24,9 +24,9 @@ import ( "strconv" "strings" + "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/vm/wizard_summary.go b/internal/verda-cli/cmd/vm/wizard_summary.go index 73b19f0..be5f086 100644 --- a/internal/verda-cli/cmd/vm/wizard_summary.go +++ b/internal/verda-cli/cmd/vm/wizard_summary.go @@ -22,8 +22,8 @@ import ( "strings" "charm.land/lipgloss/v2" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) // summaryView implements wizard.View and renders the deployment summary diff --git a/internal/verda-cli/cmd/vm/wizard_test.go b/internal/verda-cli/cmd/vm/wizard_test.go index 3c8ff97..04eb9fd 100644 --- a/internal/verda-cli/cmd/vm/wizard_test.go +++ b/internal/verda-cli/cmd/vm/wizard_test.go @@ -20,8 +20,8 @@ import ( "io" "testing" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) func TestBuildCreateFlowHappyPath(t *testing.T) { diff --git a/internal/verda-cli/cmd/volume/CLAUDE.md b/internal/verda-cli/cmd/volume/CLAUDE.md index 36ede44..3ecb377 100644 --- a/internal/verda-cli/cmd/volume/CLAUDE.md +++ b/internal/verda-cli/cmd/volume/CLAUDE.md @@ -45,5 +45,5 @@ ## Relationships - Imports `cmdutil` (`internal/verda-cli/cmd/util`) for Factory, IOStreams, DebugJSON, LongDesc, Examples - Imports `verda` SDK (`verdacloud-sdk-go/pkg/verda`) for API types and client -- Imports `tui` (`verdagostack/pkg/tui`) for Prompter interface and options (`WithDefault`, `WithConfirmDefault`, `WithPagerTitle`) +- Imports `tui` (`pkg/tui`) for Prompter interface and options (`WithDefault`, `WithConfirmDefault`, `WithPagerTitle`) - Imports `lipgloss` v2 for styled terminal output (bold, dim, warning colors) diff --git a/internal/verda-cli/cmd/volume/action.go b/internal/verda-cli/cmd/volume/action.go index 67145af..838c6bb 100644 --- a/internal/verda-cli/cmd/volume/action.go +++ b/internal/verda-cli/cmd/volume/action.go @@ -23,8 +23,8 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/volume/create.go b/internal/verda-cli/cmd/volume/create.go index 4177908..e0e397e 100644 --- a/internal/verda-cli/cmd/volume/create.go +++ b/internal/verda-cli/cmd/volume/create.go @@ -24,8 +24,8 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" + "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/cmd/volume/trash.go b/internal/verda-cli/cmd/volume/trash.go index a5a3e98..c6132dd 100644 --- a/internal/verda-cli/cmd/volume/trash.go +++ b/internal/verda-cli/cmd/volume/trash.go @@ -22,7 +22,7 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" - "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) diff --git a/internal/verda-cli/options/options.go b/internal/verda-cli/options/options.go index 9e88ed8..37326f7 100644 --- a/internal/verda-cli/options/options.go +++ b/internal/verda-cli/options/options.go @@ -23,7 +23,7 @@ import ( "github.com/spf13/pflag" "github.com/spf13/viper" - "github.com/verda-cloud/verdagostack/pkg/log" + "github.com/verda-cloud/verda-cli/pkg/log" ) const FlagConfig = "config" diff --git a/pkg/log/context.go b/pkg/log/context.go new file mode 100644 index 0000000..200ffae --- /dev/null +++ b/pkg/log/context.go @@ -0,0 +1,49 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "context" + + "go.uber.org/zap" +) + +// ContextExtractors maps field names to functions that extract their values +// from a context.Context. Registered extractors are invoked by W() to enrich +// log entries with request-scoped metadata (e.g., request ID, trace ID). +type ContextExtractors map[string]func(context.Context) string + +// WithContextExtractor returns an Option that registers the given extractors. +// Multiple calls are additive; later registrations for the same key overwrite +// earlier ones. +func WithContextExtractor(extractors ContextExtractors) Option { + return func(l *zapLogger) { + for k, v := range extractors { + l.contextExtractors[k] = v + } + } +} + +// W returns a new Logger whose output is enriched with fields extracted +// from ctx by the registered ContextExtractors. +func (l *zapLogger) W(ctx context.Context) Logger { + lc := l.clone() + for fieldName, extractor := range l.contextExtractors { + if val := extractor(ctx); val != "" { + lc.z = lc.z.With(zap.String(fieldName, val)) + } + } + return lc +} diff --git a/pkg/log/doc.go b/pkg/log/doc.go new file mode 100644 index 0000000..4d65c8c --- /dev/null +++ b/pkg/log/doc.go @@ -0,0 +1,39 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package log provides a structured, leveled logging library for the +// verda-cli project, backed by go.uber.org/zap. +// +// It exposes both printf-style (*f) and structured key-value (*w) methods at +// every severity level (Debug, Info, Warn, Error, Panic, Fatal), a global +// logger with package-level convenience functions, context-aware field +// extraction via W(), and CLI flag integration through pflag. +// +// Quick start: +// +// import "github.com/verda-cloud/verda-cli/pkg/log" +// +// // Use package-level functions with the default global logger: +// log.Infow("server starting", "port", 8080) +// log.Infof("listening on :%d", 8080) +// +// // Initialize with custom options (typically in main): +// opts := log.NewOptions() +// opts.Level = "debug" +// log.Init(opts) +// defer log.Sync() +// +// // Pass the Logger interface for dependency injection: +// svc := NewService(log.Default()) +package log // import "github.com/verda-cloud/verda-cli/pkg/log" diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 0000000..5f85438 --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,251 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Logger defines the logging interface for the verda-cli project. +// It provides both printf-style (*f) and structured (*w) logging methods +// at all standard severity levels. +type Logger interface { + Debugf(format string, args ...any) + Infof(format string, args ...any) + Warnf(format string, args ...any) + Errorf(format string, args ...any) + Panicf(format string, args ...any) + Fatalf(format string, args ...any) + + Debugw(msg string, keysAndValues ...any) + Infow(msg string, keysAndValues ...any) + Warnw(msg string, keysAndValues ...any) + Errorw(msg string, keysAndValues ...any) + Panicw(msg string, keysAndValues ...any) + Fatalw(msg string, keysAndValues ...any) + + // W returns a new Logger enriched with fields extracted from the context + // (e.g., request ID, trace ID) via registered ContextExtractors. + W(ctx context.Context) Logger + + // With returns a child Logger that always includes the given key-value pairs. + With(keysAndValues ...any) Logger + + // AddCallerSkip returns a shallow clone with increased caller-skip depth, + // useful when wrapping the Logger in higher-level helpers. + AddCallerSkip(skip int) Logger + + // Sync flushes any buffered log entries. + Sync() +} + +// Field is an alias for zapcore.Field, exposed for callers that need +// to construct typed fields for advanced use cases. +type Field = zapcore.Field + +// Option is a function that configures a zapLogger after construction. +type Option func(*zapLogger) + +type zapLogger struct { + z *zap.Logger + opts *Options + contextExtractors map[string]func(context.Context) string +} + +var _ Logger = (*zapLogger)(nil) + +var ( + mu sync.Mutex + std = NewLogger(NewOptions()) +) + +// Init replaces the global Logger with one built from opts and options. +func Init(opts *Options, options ...Option) { + mu.Lock() + defer mu.Unlock() + std = NewLogger(opts, options...) +} + +// NewLogger creates a new Logger backed by zap. +// If opts is nil, default options are used. +func NewLogger(opts *Options, options ...Option) *zapLogger { + if opts == nil { + opts = NewOptions() + } + + var zapLevel zapcore.Level + if err := zapLevel.UnmarshalText([]byte(opts.Level)); err != nil { + zapLevel = zapcore.InfoLevel + } + + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.MessageKey = "message" + encoderConfig.TimeKey = "timestamp" + encoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.Format("2006-01-02 15:04:05.000")) + } + encoderConfig.EncodeDuration = func(d time.Duration, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendFloat64(float64(d) / float64(time.Millisecond)) + } + if opts.Format == "console" && opts.EnableColor { + encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + } + + outputPaths := opts.OutputPaths + if len(outputPaths) == 0 { + outputPaths = []string{"stdout"} + } + + cfg := &zap.Config{ + DisableCaller: opts.DisableCaller, + DisableStacktrace: opts.DisableStacktrace, + Level: zap.NewAtomicLevelAt(zapLevel), + Encoding: opts.Format, + EncoderConfig: encoderConfig, + OutputPaths: outputPaths, + ErrorOutputPaths: []string{"stderr"}, + } + + z, err := cfg.Build(zap.AddStacktrace(zapcore.PanicLevel), zap.AddCallerSkip(2)) + if err != nil { + panic(err) + } + + logger := &zapLogger{ + z: z, + opts: opts, + contextExtractors: make(map[string]func(context.Context) string), + } + for _, opt := range options { + opt(logger) + } + + return logger +} + +// Default returns the global Logger. +func Default() Logger { return std } + +// Sync flushes the global Logger. +func Sync() { std.Sync() } + +func (l *zapLogger) Sync() { _ = l.z.Sync() } + +// Options returns the Options the logger was constructed with. +func (l *zapLogger) Options() *Options { return l.opts } + +// --- Package-level convenience functions (delegate to global logger) --- + +func Debugf(format string, args ...any) { std.Debugf(format, args...) } +func Debugw(msg string, keysAndValues ...any) { std.Debugw(msg, keysAndValues...) } +func Infof(format string, args ...any) { std.Infof(format, args...) } +func Infow(msg string, keysAndValues ...any) { std.Infow(msg, keysAndValues...) } +func Warnf(format string, args ...any) { std.Warnf(format, args...) } +func Warnw(msg string, keysAndValues ...any) { std.Warnw(msg, keysAndValues...) } +func Errorf(format string, args ...any) { std.Errorf(format, args...) } +func Errorw(msg string, keysAndValues ...any) { std.Errorw(msg, keysAndValues...) } +func Panicf(format string, args ...any) { std.Panicf(format, args...) } +func Panicw(msg string, keysAndValues ...any) { std.Panicw(msg, keysAndValues...) } +func Fatalf(format string, args ...any) { std.Fatalf(format, args...) } +func Fatalw(msg string, keysAndValues ...any) { std.Fatalw(msg, keysAndValues...) } +func W(ctx context.Context) Logger { return std.W(ctx) } +func With(keysAndValues ...any) Logger { return std.With(keysAndValues...) } +func AddCallerSkip(skip int) Logger { return std.AddCallerSkip(skip) } + +// --- zapLogger method implementations --- + +func (l *zapLogger) Debugf(format string, args ...any) { l.logf(zapcore.DebugLevel, format, args...) } +func (l *zapLogger) Infof(format string, args ...any) { l.logf(zapcore.InfoLevel, format, args...) } +func (l *zapLogger) Warnf(format string, args ...any) { l.logf(zapcore.WarnLevel, format, args...) } +func (l *zapLogger) Errorf(format string, args ...any) { l.logf(zapcore.ErrorLevel, format, args...) } +func (l *zapLogger) Panicf(format string, args ...any) { l.logf(zapcore.PanicLevel, format, args...) } +func (l *zapLogger) Fatalf(format string, args ...any) { l.logf(zapcore.FatalLevel, format, args...) } + +func (l *zapLogger) Debugw(msg string, keysAndValues ...any) { + l.logw(zapcore.DebugLevel, msg, keysAndValues...) +} +func (l *zapLogger) Infow(msg string, keysAndValues ...any) { + l.logw(zapcore.InfoLevel, msg, keysAndValues...) +} +func (l *zapLogger) Warnw(msg string, keysAndValues ...any) { + l.logw(zapcore.WarnLevel, msg, keysAndValues...) +} +func (l *zapLogger) Errorw(msg string, keysAndValues ...any) { + l.logw(zapcore.ErrorLevel, msg, keysAndValues...) +} +func (l *zapLogger) Panicw(msg string, keysAndValues ...any) { + l.logw(zapcore.PanicLevel, msg, keysAndValues...) +} +func (l *zapLogger) Fatalw(msg string, keysAndValues ...any) { + l.logw(zapcore.FatalLevel, msg, keysAndValues...) +} + +func (l *zapLogger) With(keysAndValues ...any) Logger { + lc := l.clone() + lc.z = lc.z.Sugar().With(keysAndValues...).Desugar() + return lc +} + +func (l *zapLogger) AddCallerSkip(skip int) Logger { + lc := l.clone() + lc.z = lc.z.WithOptions(zap.AddCallerSkip(skip)) + return lc +} + +func (l *zapLogger) clone() *zapLogger { + copied := *l + return &copied +} + +// logf dispatches printf-style log calls through zap's SugaredLogger.*f methods. +func (l *zapLogger) logf(level zapcore.Level, format string, args ...any) { + switch level { + case zapcore.DebugLevel: + l.z.Sugar().Debugf(format, args...) + case zapcore.InfoLevel: + l.z.Sugar().Infof(format, args...) + case zapcore.WarnLevel: + l.z.Sugar().Warnf(format, args...) + case zapcore.ErrorLevel: + l.z.Sugar().Errorf(format, args...) + case zapcore.PanicLevel: + l.z.Sugar().Panicf(format, args...) + case zapcore.FatalLevel: + l.z.Sugar().Fatalf(format, args...) + } +} + +// logw dispatches structured log calls through zap's SugaredLogger.*w methods. +func (l *zapLogger) logw(level zapcore.Level, msg string, keysAndValues ...any) { + switch level { + case zapcore.DebugLevel: + l.z.Sugar().Debugw(msg, keysAndValues...) + case zapcore.InfoLevel: + l.z.Sugar().Infow(msg, keysAndValues...) + case zapcore.WarnLevel: + l.z.Sugar().Warnw(msg, keysAndValues...) + case zapcore.ErrorLevel: + l.z.Sugar().Errorw(msg, keysAndValues...) + case zapcore.PanicLevel: + l.z.Sugar().Panicw(msg, keysAndValues...) + case zapcore.FatalLevel: + l.z.Sugar().Fatalw(msg, keysAndValues...) + } +} diff --git a/pkg/log/log_test.go b/pkg/log/log_test.go new file mode 100644 index 0000000..b29edaf --- /dev/null +++ b/pkg/log/log_test.go @@ -0,0 +1,386 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// newBufferedLogger creates a zapLogger that writes JSON to buf at the given level. +// Caller skip is set to 0 so test call sites show correctly. +func newBufferedLogger(buf *bytes.Buffer, level zapcore.Level) *zapLogger { + encoder := zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) + core := zapcore.NewCore(encoder, zapcore.AddSync(buf), level) + z := zap.New(core) // no caller skip for test clarity + return &zapLogger{ + z: z, + opts: NewOptions(), + contextExtractors: make(map[string]func(context.Context) string), + } +} + +// parseLine unmarshals one JSON log line into a map. +func parseLine(t *testing.T, line string) map[string]any { + t.Helper() + m := make(map[string]any) + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("failed to parse log line %q: %v", line, err) + } + return m +} + +// lastLine returns the final non-empty line from the buffer. +func lastLine(buf *bytes.Buffer) string { + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + return lines[len(lines)-1] +} + +func TestNewLogger_Defaults(t *testing.T) { + logger := NewLogger(nil) + if logger == nil { + t.Fatal("NewLogger(nil) returned nil") + } + if logger.opts.Level != "info" { + t.Errorf("expected default level 'info', got %q", logger.opts.Level) + } + if logger.opts.Format != "console" { + t.Errorf("expected default format 'console', got %q", logger.opts.Format) + } +} + +func TestNewLogger_InvalidLevel(t *testing.T) { + opts := &Options{Level: "not-a-level", Format: "json", OutputPaths: []string{"stdout"}} + logger := NewLogger(opts) + if logger == nil { + t.Fatal("NewLogger returned nil for invalid level") + } +} + +func TestInfow_StructuredOutput(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + + l.Infow("hello", "user", "alice", "count", 42) + _ = l.z.Sync() + + m := parseLine(t, lastLine(&buf)) + if m["msg"] != "hello" { + t.Errorf("expected msg 'hello', got %v", m["msg"]) + } + if m["user"] != "alice" { + t.Errorf("expected user 'alice', got %v", m["user"]) + } + if m["count"] != float64(42) { + t.Errorf("expected count 42, got %v", m["count"]) + } +} + +func TestInfof_PrintfFormatting(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + + l.Infof("user %s has %d items", "bob", 5) + _ = l.z.Sync() + + m := parseLine(t, lastLine(&buf)) + msg, _ := m["msg"].(string) + if msg != "user bob has 5 items" { + t.Errorf("expected formatted message, got %q", msg) + } +} + +func TestLogfVsLogw_DifferentBehavior(t *testing.T) { + var bufF, bufW bytes.Buffer + lf := newBufferedLogger(&bufF, zapcore.DebugLevel) + lw := newBufferedLogger(&bufW, zapcore.DebugLevel) + + lf.Infof("count=%d", 10) + lw.Infow("count", "value", 10) + _ = lf.z.Sync() + _ = lw.z.Sync() + + mf := parseLine(t, lastLine(&bufF)) + mw := parseLine(t, lastLine(&bufW)) + + // Printf should produce a formatted message string + if mf["msg"] != "count=10" { + t.Errorf("Infof: expected msg 'count=10', got %q", mf["msg"]) + } + // Structured should produce "count" as message and "value" as a field + if mw["msg"] != "count" { + t.Errorf("Infow: expected msg 'count', got %q", mw["msg"]) + } + if mw["value"] != float64(10) { + t.Errorf("Infow: expected value=10, got %v", mw["value"]) + } +} + +func TestWith_ChildLogger(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + + child := l.With("component", "auth") + child.Infow("token issued", "user", "carol") + child.(interface{ Sync() }).Sync() + + m := parseLine(t, lastLine(&buf)) + if m["component"] != "auth" { + t.Errorf("expected component 'auth', got %v", m["component"]) + } + if m["user"] != "carol" { + t.Errorf("expected user 'carol', got %v", m["user"]) + } +} + +func TestWith_DoesNotMutateParent(t *testing.T) { + var buf bytes.Buffer + parent := newBufferedLogger(&buf, zapcore.DebugLevel) + + _ = parent.With("child_field", "yes") + parent.Infow("parent log") + _ = parent.z.Sync() + + m := parseLine(t, lastLine(&buf)) + if _, exists := m["child_field"]; exists { + t.Error("With() should not mutate the parent logger") + } +} + +func TestW_ContextExtraction(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + l.contextExtractors["request.id"] = func(ctx context.Context) string { + if v, ok := ctx.Value("rid").(string); ok { + return v + } + return "" + } + + ctx := context.WithValue(context.Background(), "rid", "abc-123") //nolint:staticcheck // test matches extractor key type + l.W(ctx).Infow("handled") + _ = l.z.Sync() + + m := parseLine(t, lastLine(&buf)) + if m["request.id"] != "abc-123" { + t.Errorf("expected request.id 'abc-123', got %v", m["request.id"]) + } +} + +func TestW_EmptyContextValue_OmitsField(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + l.contextExtractors["trace.id"] = func(ctx context.Context) string { return "" } + + l.W(context.Background()).Infow("no trace") + _ = l.z.Sync() + + m := parseLine(t, lastLine(&buf)) + if _, exists := m["trace.id"]; exists { + t.Error("W() should omit fields with empty extractor values") + } +} + +func TestAddCallerSkip(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + + skipped := l.AddCallerSkip(5) + if skipped == nil { + t.Fatal("AddCallerSkip returned nil") + } + // Ensure it's a different instance + if skipped.(*zapLogger) == l { + t.Error("AddCallerSkip should return a new logger, not the same one") + } +} + +func TestLevelFiltering(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.WarnLevel) + + l.Infow("should be filtered") + l.Debugf("also filtered") + l.Warnw("should appear") + _ = l.z.Sync() + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(lines) != 1 { + t.Fatalf("expected 1 log line at warn level, got %d: %v", len(lines), lines) + } + m := parseLine(t, lines[0]) + if m["msg"] != "should appear" { + t.Errorf("expected msg 'should appear', got %v", m["msg"]) + } +} + +func TestAllLevels_Structured(t *testing.T) { + tests := []struct { + name string + logFn func(l *zapLogger) + level string + }{ + {"Debugw", func(l *zapLogger) { l.Debugw("d") }, "debug"}, + {"Infow", func(l *zapLogger) { l.Infow("i") }, "info"}, + {"Warnw", func(l *zapLogger) { l.Warnw("w") }, "warn"}, + {"Errorw", func(l *zapLogger) { l.Errorw("e") }, "error"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + tc.logFn(l) + _ = l.z.Sync() + + if buf.Len() == 0 { + t.Errorf("%s produced no output", tc.name) + } + }) + } +} + +func TestAllLevels_Printf(t *testing.T) { + tests := []struct { + name string + logFn func(l *zapLogger) + }{ + {"Debugf", func(l *zapLogger) { l.Debugf("val=%d", 1) }}, + {"Infof", func(l *zapLogger) { l.Infof("val=%d", 2) }}, + {"Warnf", func(l *zapLogger) { l.Warnf("val=%d", 3) }}, + {"Errorf", func(l *zapLogger) { l.Errorf("val=%d", 4) }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + tc.logFn(l) + _ = l.z.Sync() + + if buf.Len() == 0 { + t.Errorf("%s produced no output", tc.name) + } + }) + } +} + +func TestInit_ReplacesGlobal(t *testing.T) { + oldStd := std + defer func() { + mu.Lock() + std = oldStd + mu.Unlock() + }() + + opts := &Options{Level: "debug", Format: "json", OutputPaths: []string{"stdout"}} + Init(opts) + + if Default() == nil { + t.Fatal("Default() returned nil after Init") + } +} + +func TestInit_PassesOptions(t *testing.T) { + oldStd := std + defer func() { + mu.Lock() + std = oldStd + mu.Unlock() + }() + + called := false + testOption := func(l *zapLogger) { + called = true + } + + opts := &Options{Level: "info", Format: "json", OutputPaths: []string{"stdout"}} + Init(opts, testOption) + + if !called { + t.Error("Init should pass functional options to NewLogger") + } +} + +func TestSync_NoPanic(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + l.Sync() // should not panic +} + +func TestDefault_NotNil(t *testing.T) { + if Default() == nil { + t.Fatal("Default() should never return nil") + } +} + +func TestErrorw_ConsistentSignature(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + + l.Errorw("db failed", "err", "connection refused", "host", "db.local") + _ = l.z.Sync() + + m := parseLine(t, lastLine(&buf)) + if m["msg"] != "db failed" { + t.Errorf("expected msg 'db failed', got %v", m["msg"]) + } + if m["err"] != "connection refused" { + t.Errorf("expected err 'connection refused', got %v", m["err"]) + } + if m["host"] != "db.local" { + t.Errorf("expected host 'db.local', got %v", m["host"]) + } +} + +func TestClone_Independence(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + + c := l.clone() + if c == l { + t.Error("clone should return a different pointer") + } + // Mutating clone's opts should not affect original + c.opts = &Options{Level: "error"} + if l.opts.Level == "error" { + t.Error("clone opts mutation leaked to original") + } +} + +func TestWithContextExtractor_Option(t *testing.T) { + var buf bytes.Buffer + l := newBufferedLogger(&buf, zapcore.DebugLevel) + + opt := WithContextExtractor(ContextExtractors{ + "tenant": func(ctx context.Context) string { return "acme" }, + }) + opt(l) + + ctx := context.Background() + l.W(ctx).Infow("with tenant") + _ = l.z.Sync() + + m := parseLine(t, lastLine(&buf)) + if m["tenant"] != "acme" { + t.Errorf("expected tenant 'acme', got %v", m["tenant"]) + } +} diff --git a/pkg/log/options.go b/pkg/log/options.go new file mode 100644 index 0000000..01e2fba --- /dev/null +++ b/pkg/log/options.go @@ -0,0 +1,74 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "github.com/spf13/pflag" + "go.uber.org/zap/zapcore" +) + +// Options holds configuration for the Logger. +type Options struct { + // DisableCaller controls whether caller information (file:line) is included. + DisableCaller bool `json:"disable-caller,omitempty" mapstructure:"disable-caller"` + + // DisableStacktrace controls whether stack traces are recorded + // for messages at or above panic level. + DisableStacktrace bool `json:"disable-stacktrace,omitempty" mapstructure:"disable-stacktrace"` + + // EnableColor enables ANSI color output in console format. + EnableColor bool `json:"enable-color" mapstructure:"enable-color"` + + // Level sets the minimum enabled log level. + // Valid values: debug, info, warn, error, dpanic, panic, fatal. + Level string `json:"level,omitempty" mapstructure:"level"` + + // Format sets the log output encoding. + // Valid values: console, json. + Format string `json:"format,omitempty" mapstructure:"format"` + + // OutputPaths is a list of URLs or file paths to write log output to. + OutputPaths []string `json:"output-paths,omitempty" mapstructure:"output-paths"` +} + +// NewOptions returns an Options with production-ready defaults. +func NewOptions() *Options { + return &Options{ + Level: zapcore.InfoLevel.String(), + Format: "console", + OutputPaths: []string{"stdout"}, + } +} + +// Validate checks the Options fields for invalid values. +func (o *Options) Validate() []error { + var errs []error + return errs +} + +// AddFlags registers CLI flags for all Options fields on the given FlagSet. +func (o *Options) AddFlags(fs *pflag.FlagSet) { + fs.StringVar(&o.Level, "log.level", o.Level, "Minimum log output `LEVEL`.") + fs.BoolVar(&o.DisableCaller, "log.disable-caller", o.DisableCaller, + "Disable output of caller information in the log.") + fs.BoolVar(&o.DisableStacktrace, "log.disable-stacktrace", o.DisableStacktrace, + "Disable the log to record a stack trace for all messages at or above panic level.") + fs.BoolVar(&o.EnableColor, "log.enable-color", o.EnableColor, + "Enable output ANSI colors in plain format logs.") + fs.StringVar(&o.Format, "log.format", o.Format, + "Log output `FORMAT`, support console or json format.") + fs.StringSliceVar(&o.OutputPaths, "log.output-paths", o.OutputPaths, + "Output paths of log.") +} diff --git a/pkg/log/options_test.go b/pkg/log/options_test.go new file mode 100644 index 0000000..7678b28 --- /dev/null +++ b/pkg/log/options_test.go @@ -0,0 +1,138 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package log + +import ( + "testing" + + "github.com/spf13/pflag" +) + +func TestNewOptions_Defaults(t *testing.T) { + opts := NewOptions() + + if opts.Level != "info" { + t.Errorf("expected level 'info', got %q", opts.Level) + } + if opts.Format != "console" { + t.Errorf("expected format 'console', got %q", opts.Format) + } + if len(opts.OutputPaths) != 1 || opts.OutputPaths[0] != "stdout" { + t.Errorf("expected output paths [stdout], got %v", opts.OutputPaths) + } + if opts.DisableCaller { + t.Error("DisableCaller should default to false") + } + if opts.DisableStacktrace { + t.Error("DisableStacktrace should default to false") + } + if opts.EnableColor { + t.Error("EnableColor should default to false") + } +} + +func TestOptions_Validate(t *testing.T) { + opts := NewOptions() + errs := opts.Validate() + if len(errs) != 0 { + t.Errorf("expected no validation errors for defaults, got %v", errs) + } +} + +func TestOptions_AddFlags(t *testing.T) { + opts := NewOptions() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + opts.AddFlags(fs) + + expectedFlags := []string{ + "log.level", + "log.disable-caller", + "log.disable-stacktrace", + "log.enable-color", + "log.format", + "log.output-paths", + } + + for _, name := range expectedFlags { + if fs.Lookup(name) == nil { + t.Errorf("expected flag %q to be registered", name) + } + } +} + +func TestOptions_AddFlags_ParseLevel(t *testing.T) { + opts := NewOptions() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + opts.AddFlags(fs) + + if err := fs.Parse([]string{"--log.level=debug"}); err != nil { + t.Fatalf("flag parsing failed: %v", err) + } + if opts.Level != "debug" { + t.Errorf("expected level 'debug' after parsing, got %q", opts.Level) + } +} + +func TestOptions_AddFlags_ParseFormat(t *testing.T) { + opts := NewOptions() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + opts.AddFlags(fs) + + if err := fs.Parse([]string{"--log.format=json"}); err != nil { + t.Fatalf("flag parsing failed: %v", err) + } + if opts.Format != "json" { + t.Errorf("expected format 'json' after parsing, got %q", opts.Format) + } +} + +func TestOptions_AddFlags_ParseBoolFlags(t *testing.T) { + opts := NewOptions() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + opts.AddFlags(fs) + + if err := fs.Parse([]string{ + "--log.disable-caller", + "--log.disable-stacktrace", + "--log.enable-color", + }); err != nil { + t.Fatalf("flag parsing failed: %v", err) + } + if !opts.DisableCaller { + t.Error("expected DisableCaller=true after --log.disable-caller") + } + if !opts.DisableStacktrace { + t.Error("expected DisableStacktrace=true after --log.disable-stacktrace") + } + if !opts.EnableColor { + t.Error("expected EnableColor=true after --log.enable-color") + } +} + +func TestOptions_AddFlags_ParseOutputPaths(t *testing.T) { + opts := NewOptions() + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + opts.AddFlags(fs) + + if err := fs.Parse([]string{"--log.output-paths=stdout,/var/log/app.log"}); err != nil { + t.Fatalf("flag parsing failed: %v", err) + } + if len(opts.OutputPaths) != 2 { + t.Fatalf("expected 2 output paths, got %d: %v", len(opts.OutputPaths), opts.OutputPaths) + } + if opts.OutputPaths[0] != "stdout" || opts.OutputPaths[1] != "/var/log/app.log" { + t.Errorf("unexpected output paths: %v", opts.OutputPaths) + } +} diff --git a/pkg/tui/bubbletea/confirm.go b/pkg/tui/bubbletea/confirm.go new file mode 100644 index 0000000..9f64ec0 --- /dev/null +++ b/pkg/tui/bubbletea/confirm.go @@ -0,0 +1,175 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +type confirmModel struct { + prompt string + value bool + decided bool + aborted bool + interrupted bool // true for Ctrl+C (hard cancel), false for Esc (soft cancel) + bindings []KeyBinding[confirmModel] +} + +// DefaultConfirmBindings returns a fresh copy of the canonical +// binding set. Stable IDs: yes-no, confirm, esc, exit. +func DefaultConfirmBindings() []KeyBinding[confirmModel] { + return []KeyBinding[confirmModel]{ + { + ID: "yes-no", + Match: MatchText(), + Label: func(*confirmModel) string { return "y/n" }, + Handle: func(m *confirmModel, msg tea.KeyPressMsg) (tea.Cmd, bool) { + switch msg.Text { + case "y", "Y": + m.value = true + m.decided = true + return tea.Quit, true + case "n", "N": + m.value = false + m.decided = true + return tea.Quit, true + } + return nil, false // unrecognized printable; no later binding claims it + }, + }, + { + ID: "confirm", + Match: MatchKey(tea.KeyEnter), + Label: func(*confirmModel) string { return "enter confirm" }, + Handle: func(m *confirmModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.decided = true + return tea.Quit, true + }, + }, + { + ID: "esc", + Match: MatchKey(tea.KeyEscape), + Label: func(*confirmModel) string { return hintEscBack }, + Handle: func(m *confirmModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.aborted = true + return func() tea.Msg { return GoBackMsg{} }, true + }, + }, + { + ID: "exit", + Match: MatchRune('c', tea.ModCtrl), + Label: func(*confirmModel) string { return hintCtrlCExit }, + Handle: func(m *confirmModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.interrupted = true + return tea.Quit, true + }, + }, + } +} + +func newConfirmModel(prompt string, cfg tui.ConfirmConfig) confirmModel { + defaults := ApplyBindingOverrides(DefaultConfirmBindings(), cfg.RelabelByID, cfg.HiddenByID) + var bindings []KeyBinding[confirmModel] + if extras, ok := cfg.ExtraBindings.([]KeyBinding[confirmModel]); ok && len(extras) > 0 { + bindings = make([]KeyBinding[confirmModel], 0, len(extras)+len(defaults)) + bindings = append(bindings, extras...) + bindings = append(bindings, defaults...) + } else { + bindings = defaults + } + return confirmModel{ + prompt: prompt, + value: cfg.Default, + bindings: bindings, + } +} + +// WithConfirmAddBindings prepends extras so they outrank the default +// catch-all matchers. See WithSelectAddBindings for semantics. +func WithConfirmAddBindings(extras ...KeyBinding[confirmModel]) tui.ConfirmOption { + return func(c *tui.ConfirmConfig) { + existing, _ := c.ExtraBindings.([]KeyBinding[confirmModel]) + c.ExtraBindings = append(existing, extras...) + } +} + +func (m confirmModel) Init() tea.Cmd { return nil } + +func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(GoBackMsg); ok { + return m, tea.Quit // standalone mode quit; wizard composite intercepts before this + } + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + cmd, _ := Dispatch(&m, m.bindings, key) + return m, cmd +} + +// Hints derives key hints from the resolved bindings. +func (m confirmModel) Hints() []string { + return HintsFor(&m, m.bindings) +} + +// Result returns the confirm value after the user decides. +func (m confirmModel) Result() (any, bool) { + return m.value, m.decided +} + +// NewConfirmPrompt creates a confirm prompt model for use in the wizard composite. +func NewConfirmPrompt(prompt string, cfg tui.ConfirmConfig) PromptModel { + return newConfirmModel(prompt, cfg) +} + +func (m confirmModel) View() tea.View { + hint := "y/N" + if m.value { + hint = "Y/n" + } + if m.decided || m.aborted { + answer := "No" + if m.value { + answer = "Yes" + } + return tea.NewView(fmt.Sprintf("%s %s %s\n", promptStyle.Render("?"), titleStyle.Render(m.prompt), answerStyle.Render(answer))) + } + return tea.NewView(fmt.Sprintf("%s %s %s ", promptStyle.Render("?"), titleStyle.Render(m.prompt), hintStyle.Render("["+hint+"]"))) +} + +// Confirm implements tui.Prompter. +func (p *Prompter) Confirm(ctx context.Context, prompt string, opts ...tui.ConfirmOption) (bool, error) { + cfg := tui.ResolveConfirmConfig(opts) + model := newConfirmModel(prompt, cfg) + + r := p.runProgram(ctx, model) + if r.interrupted { + return false, tui.ErrInterrupted + } + if r.err != nil { + return false, fmt.Errorf("confirm prompt: %w", r.err) + } + + m := r.model.(confirmModel) + if m.aborted { + return false, context.Canceled + } + return m.value, nil +} diff --git a/pkg/tui/bubbletea/confirm_bindings_test.go b/pkg/tui/bubbletea/confirm_bindings_test.go new file mode 100644 index 0000000..7bb437e --- /dev/null +++ b/pkg/tui/bubbletea/confirm_bindings_test.go @@ -0,0 +1,126 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func TestConfirmBindings_DefaultHintsOrder(t *testing.T) { + cfg := tui.ResolveConfirmConfig(nil) + m := newConfirmModel("Sure?", cfg) + got := strings.Join(m.Hints(), " · ") + want := "y/n · enter confirm · esc back · ctrl+c exit" + if got != want { + t.Errorf("default hint order drifted\n got: %s\nwant: %s", got, want) + } +} + +func TestConfirmBindings_YesAndNo(t *testing.T) { + for _, tc := range []struct { + text string + expect bool + }{ + {"y", true}, + {"Y", true}, + {"n", false}, + {"N", false}, + } { + m := newConfirmModel("Sure?", tui.ResolveConfirmConfig(nil)) + updated, _ := m.Update(tea.KeyPressMsg{Code: rune(tc.text[0]), Text: tc.text}) + got := updated.(confirmModel) + if !got.decided { + t.Errorf("text=%q expected decided", tc.text) + } + if got.value != tc.expect { + t.Errorf("text=%q value = %v, want %v", tc.text, got.value, tc.expect) + } + } +} + +func TestConfirmBindings_EnterUsesDefault(t *testing.T) { + cfg := tui.ResolveConfirmConfig([]tui.ConfirmOption{tui.WithConfirmDefault(true)}) + m := newConfirmModel("Sure?", cfg) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + got := updated.(confirmModel) + if !got.decided || !got.value { + t.Errorf("enter should commit default true, got decided=%v value=%v", got.decided, got.value) + } +} + +func TestConfirmBindings_CtrlCInterrupts(t *testing.T) { + m := newConfirmModel("Sure?", tui.ResolveConfirmConfig(nil)) + updated, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + got := updated.(confirmModel) + if !got.interrupted { + t.Error("ctrl+c should set interrupted") + } +} + +func TestConfirmBindings_Relabel(t *testing.T) { + cfg := tui.ResolveConfirmConfig([]tui.ConfirmOption{ + tui.WithConfirmRelabel("yes-no", "Y / N"), + tui.WithConfirmRelabel("confirm", "↵ ok"), + }) + m := newConfirmModel("Sure?", cfg) + got := strings.Join(m.Hints(), " · ") + want := "Y / N · ↵ ok · esc back · ctrl+c exit" + if got != want { + t.Errorf("relabel mismatch\n got: %s\nwant: %s", got, want) + } +} + +func TestConfirmBindings_Hide(t *testing.T) { + cfg := tui.ResolveConfirmConfig([]tui.ConfirmOption{ + tui.WithConfirmHide("exit", "esc"), + }) + m := newConfirmModel("Sure?", cfg) + hints := m.Hints() + if containsString(hints, "ctrl+c exit") || containsString(hints, "esc back") { + t.Errorf("hidden entries leaked, got %v", hints) + } + // ctrl+c still interrupts. + updated, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if !updated.(confirmModel).interrupted { + t.Error("ctrl+c key handling should remain after hide") + } +} + +func TestConfirmBindings_AddBinding(t *testing.T) { + fired := false + help := KeyBinding[confirmModel]{ + ID: "help", + Match: MatchRune('?'), + Label: func(*confirmModel) string { return "? help" }, + Handle: func(_ *confirmModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + fired = true + return nil, true + }, + } + cfg := tui.ResolveConfirmConfig([]tui.ConfirmOption{WithConfirmAddBindings(help)}) + m := newConfirmModel("Sure?", cfg) + if !containsString(m.Hints(), "? help") { + t.Errorf("custom label missing, got %v", m.Hints()) + } + m.Update(tea.KeyPressMsg{Code: '?', Text: "?"}) + if !fired { + t.Error("custom handler did not fire") + } +} diff --git a/pkg/tui/bubbletea/confirm_prompt_test.go b/pkg/tui/bubbletea/confirm_prompt_test.go new file mode 100644 index 0000000..09315ac --- /dev/null +++ b/pkg/tui/bubbletea/confirm_prompt_test.go @@ -0,0 +1,103 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func TestConfirmPrompt_Hints(t *testing.T) { + cfg := tui.ResolveConfirmConfig(nil) + m := NewConfirmPrompt("Continue?", cfg) + hints := m.Hints() + if len(hints) == 0 { + t.Fatal("expected hints") + } +} + +func TestConfirmPrompt_YesKey(t *testing.T) { + cfg := tui.ResolveConfirmConfig(nil) + m := NewConfirmPrompt("Continue?", cfg) + + updated, _ := m.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + m = updated.(PromptModel) + + val, done := m.Result() + if !done { + t.Fatal("expected done after y") + } + if val != true { + t.Errorf("expected true, got %v", val) + } +} + +func TestConfirmPrompt_NoKey(t *testing.T) { + cfg := tui.ResolveConfirmConfig(nil) + m := NewConfirmPrompt("Continue?", cfg) + + updated, _ := m.Update(tea.KeyPressMsg{Code: 'n', Text: "n"}) + m = updated.(PromptModel) + + val, done := m.Result() + if !done { + t.Fatal("expected done after n") + } + if val != false { + t.Errorf("expected false, got %v", val) + } +} + +func TestConfirmPrompt_EnterUsesDefault(t *testing.T) { + cfg := tui.ResolveConfirmConfig([]tui.ConfirmOption{tui.WithConfirmDefault(true)}) + m := NewConfirmPrompt("Continue?", cfg) + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(PromptModel) + + val, done := m.Result() + if !done { + t.Fatal("expected done after Enter") + } + if val != true { + t.Errorf("expected true (default), got %v", val) + } +} + +func TestConfirmPrompt_EscGoBack(t *testing.T) { + cfg := tui.ResolveConfirmConfig(nil) + m := NewConfirmPrompt("Continue?", cfg) + + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil { + t.Fatal("expected command from Esc") + } + msg := cmd() + if _, ok := msg.(GoBackMsg); !ok { + t.Fatalf("expected GoBackMsg, got %T", msg) + } +} + +func TestConfirmPrompt_Result_NotDoneInitially(t *testing.T) { + cfg := tui.ResolveConfirmConfig(nil) + m := NewConfirmPrompt("Continue?", cfg) + _, done := m.Result() + if done { + t.Fatal("should not be done initially") + } +} diff --git a/pkg/tui/bubbletea/doc.go b/pkg/tui/bubbletea/doc.go new file mode 100644 index 0000000..ab37a67 --- /dev/null +++ b/pkg/tui/bubbletea/doc.go @@ -0,0 +1,17 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package bubbletea provides a Bubbletea-backed implementation of the +// tui.Prompter interface. +package bubbletea diff --git a/pkg/tui/bubbletea/editor.go b/pkg/tui/bubbletea/editor.go new file mode 100644 index 0000000..e51a1f5 --- /dev/null +++ b/pkg/tui/bubbletea/editor.go @@ -0,0 +1,114 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + "strings" + + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +type editorModel struct { + prompt string + textarea textarea.Model + hint string // resolved: caller override or library default + showHint bool // false = suppress the affordance line (WithEditorNoHint) + summary func(lines int) string // resolved: caller override or library default + submitted bool + aborted bool +} + +func newEditorModel(prompt string, cfg tui.EditorConfig) editorModel { + ta := textarea.New() + ta.SetValue(cfg.Default) + ta.ShowLineNumbers = true + ta.Focus() + + hint := cfg.Hint + if hint == "" { + hint = "ctrl+d to submit, esc to cancel" + } + summary := cfg.Summary + if summary == nil { + summary = func(lines int) string { return fmt.Sprintf("[%d lines]", lines) } + } + + return editorModel{ + prompt: prompt, + textarea: ta, + hint: hint, + showHint: !cfg.NoHint, + summary: summary, + } +} + +func (m editorModel) Init() tea.Cmd { return textarea.Blink } + +func (m editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+d": + m.submitted = true + return m, tea.Quit + case keyCtrlC, keyEsc: + m.aborted = true + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd +} + +func (m editorModel) View() tea.View { + if m.submitted { + lines := strings.Count(m.textarea.Value(), "\n") + 1 + return tea.NewView(fmt.Sprintf("? %s %s\n", m.prompt, m.summary(lines))) + } + if !m.showHint { + return tea.NewView(fmt.Sprintf("? %s\n%s", m.prompt, m.textarea.View())) + } + return tea.NewView(fmt.Sprintf("? %s (%s)\n%s", m.prompt, m.hint, m.textarea.View())) +} + +// Editor implements tui.Prompter. +func (p *Prompter) Editor(ctx context.Context, prompt string, opts ...tui.EditorOption) (string, error) { + cfg := tui.ResolveEditorConfig(opts) + model := newEditorModel(prompt, cfg) + + program := tea.NewProgram(model, + tea.WithInput(p.in), + tea.WithOutput(p.out), + tea.WithContext(ctx), + ) + + result, err := program.Run() + if err != nil { + return "", fmt.Errorf("editor prompt: %w", err) + } + + m := result.(editorModel) + if m.aborted { + return "", context.Canceled + } + return m.textarea.Value(), nil +} diff --git a/pkg/tui/bubbletea/editor_test.go b/pkg/tui/bubbletea/editor_test.go new file mode 100644 index 0000000..83cdb1f --- /dev/null +++ b/pkg/tui/bubbletea/editor_test.go @@ -0,0 +1,81 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "fmt" + "strings" + "testing" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func newEd(opts ...func(*tui.EditorConfig)) editorModel { + cfg := tui.EditorConfig{} + for _, o := range opts { + o(&cfg) + } + return newEditorModel("Notes", cfg) +} + +func TestEditorModel_DefaultHint(t *testing.T) { + m := newEd() + if !strings.Contains(m.View().Content, "(ctrl+d to submit, esc to cancel)") { + t.Errorf("expected default hint, got %q", m.View().Content) + } +} + +func TestEditorModel_HintOverride(t *testing.T) { + m := newEd(func(c *tui.EditorConfig) { c.Hint = "⌘↵ to save" }) + if !strings.Contains(m.View().Content, "(⌘↵ to save)") { + t.Errorf("expected overridden hint, got %q", m.View().Content) + } +} + +func TestEditorModel_NoHint(t *testing.T) { + m := newEd(func(c *tui.EditorConfig) { c.NoHint = true }) + if strings.Contains(m.View().Content, "(") { + t.Errorf("expected no affordance parentheses, got %q", m.View().Content) + } +} + +func TestEditorModel_NoHintBeatsHint(t *testing.T) { + m := newEd(func(c *tui.EditorConfig) { + c.Hint = "should not show" + c.NoHint = true + }) + if strings.Contains(m.View().Content, "should not show") { + t.Errorf("NoHint should take precedence over Hint, got %q", m.View().Content) + } +} + +func TestEditorModel_DefaultSummary(t *testing.T) { + m := newEd(func(c *tui.EditorConfig) { c.Default = "a\nb\nc" }) + m.submitted = true + if !strings.Contains(m.View().Content, "[3 lines]") { + t.Errorf("expected default summary, got %q", m.View().Content) + } +} + +func TestEditorModel_SummaryOverride(t *testing.T) { + m := newEd(func(c *tui.EditorConfig) { + c.Default = "a\nb" + c.Summary = func(lines int) string { return fmt.Sprintf("saved %d line(s)", lines) } + }) + m.submitted = true + if !strings.Contains(m.View().Content, "saved 2 line(s)") { + t.Errorf("expected overridden summary, got %q", m.View().Content) + } +} diff --git a/pkg/tui/bubbletea/keybinding.go b/pkg/tui/bubbletea/keybinding.go new file mode 100644 index 0000000..7ef59d8 --- /dev/null +++ b/pkg/tui/bubbletea/keybinding.go @@ -0,0 +1,130 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "slices" + + tea "charm.land/bubbletea/v2" +) + +// KeyBinding pairs a matcher with a state-aware label and handler. +// Generic over M so handlers mutate state directly; empty Label hides +// the entry from the hint bar without disabling Handle. +type KeyBinding[M any] struct { + // Stable kebab-case identifier for relabel/hide overrides. + ID string + + // Pure function of msg; must not inspect model state. + Match func(tea.KeyPressMsg) bool + + // Dynamic hint text; "" hides this entry. + Label func(*M) string + + // Returns (cmd, stop). stop=false falls through to next matching binding. + Handle func(*M, tea.KeyPressMsg) (tea.Cmd, bool) +} + +// MatchKey matches any of the given key codes. bubbletea v2's named +// key constants (KeyUp, KeyEnter, …) are typed rune. +func MatchKey(codes ...rune) func(tea.KeyPressMsg) bool { + return func(msg tea.KeyPressMsg) bool { + return slices.Contains(codes, msg.Code) + } +} + +// MatchRune matches r, optionally constrained by mod. With no mod +// the binding fires only when no modifier is held — MatchRune('k') +// won't match Ctrl+K. +func MatchRune(r rune, mod ...tea.KeyMod) func(tea.KeyPressMsg) bool { + var required tea.KeyMod + for _, m := range mod { + required |= m + } + return func(msg tea.KeyPressMsg) bool { + if msg.Code != r { + return false + } + if required == 0 { + return msg.Mod == 0 + } + return msg.Mod&required == required + } +} + +// MatchText fires on any key event carrying printable text. Order after +// specific rune matchers so they get first claim. +func MatchText() func(tea.KeyPressMsg) bool { + return func(msg tea.KeyPressMsg) bool { return msg.Text != "" } +} + +// Dispatch returns the cmd of the first matching binding whose Handle +// returns stop=true. A handler may return (nil, false) to fall through +// to the next matching binding even when its Match fired. +func Dispatch[M any](m *M, bindings []KeyBinding[M], msg tea.KeyPressMsg) (tea.Cmd, bool) { + for _, b := range bindings { + if b.Match == nil || b.Handle == nil { + continue + } + if !b.Match(msg) { + continue + } + cmd, stop := b.Handle(m, msg) + if stop { + return cmd, true + } + } + return nil, false +} + +// HintsFor returns non-empty Label values in declaration order. +func HintsFor[M any](m *M, bindings []KeyBinding[M]) []string { + out := make([]string, 0, len(bindings)) + for _, b := range bindings { + if b.Label == nil { + continue + } + if lbl := b.Label(m); lbl != "" { + out = append(out, lbl) + } + } + return out +} + +// ApplyBindingOverrides returns a copy of bindings with per-ID relabels +// applied and hidden IDs replaced by empty-label closures. The input +// slice is not mutated. +func ApplyBindingOverrides[M any](bindings []KeyBinding[M], relabels map[string]string, hidden []string) []KeyBinding[M] { + if len(relabels) == 0 && len(hidden) == 0 { + return bindings + } + hiddenSet := make(map[string]struct{}, len(hidden)) + for _, id := range hidden { + hiddenSet[id] = struct{}{} + } + out := make([]KeyBinding[M], len(bindings)) + for i, b := range bindings { + out[i] = b + if _, ok := hiddenSet[b.ID]; ok { + out[i].Label = func(*M) string { return "" } + continue + } + if lbl, ok := relabels[b.ID]; ok { + label := lbl + out[i].Label = func(*M) string { return label } + } + } + return out +} diff --git a/pkg/tui/bubbletea/keybinding_test.go b/pkg/tui/bubbletea/keybinding_test.go new file mode 100644 index 0000000..7b2f151 --- /dev/null +++ b/pkg/tui/bubbletea/keybinding_test.go @@ -0,0 +1,190 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "reflect" + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestMatchKey_SingleAndMulti(t *testing.T) { + upOnly := MatchKey(tea.KeyUp) + upDown := MatchKey(tea.KeyUp, tea.KeyDown) + + if !upOnly(tea.KeyPressMsg{Code: tea.KeyUp}) { + t.Error("upOnly should match KeyUp") + } + if upOnly(tea.KeyPressMsg{Code: tea.KeyDown}) { + t.Error("upOnly should NOT match KeyDown") + } + if !upDown(tea.KeyPressMsg{Code: tea.KeyDown}) { + t.Error("upDown should match KeyDown") + } + if upDown(tea.KeyPressMsg{Code: tea.KeyEnter}) { + t.Error("upDown should NOT match KeyEnter") + } +} + +func TestMatchRune_PlainAndModifier(t *testing.T) { + plainK := MatchRune('k') + ctrlC := MatchRune('c', tea.ModCtrl) + + if !plainK(tea.KeyPressMsg{Code: 'k'}) { + t.Error("plainK should match plain 'k'") + } + if plainK(tea.KeyPressMsg{Code: 'k', Mod: tea.ModCtrl}) { + t.Error("plainK should NOT match Ctrl+K (modifier should disqualify)") + } + if !ctrlC(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) { + t.Error("ctrlC should match Ctrl+C") + } + if ctrlC(tea.KeyPressMsg{Code: 'c'}) { + t.Error("ctrlC should NOT match plain 'c'") + } +} + +func TestMatchText_OnlyMatchesPrintable(t *testing.T) { + mt := MatchText() + if mt(tea.KeyPressMsg{Code: tea.KeyEnter}) { + t.Error("MatchText should NOT match Enter (no Text)") + } + if !mt(tea.KeyPressMsg{Code: 'a', Text: "a"}) { + t.Error("MatchText should match printable 'a'") + } +} + +// Dispatch covers: first-match wins, pass-through (stop=false), +// stop=true short-circuits, no-match returns (nil, false). +func TestDispatch_OrderingAndStop(t *testing.T) { + type state struct { + ran []string + } + + calls := func(id string) func(*state, tea.KeyPressMsg) (tea.Cmd, bool) { + return func(s *state, _ tea.KeyPressMsg) (tea.Cmd, bool) { + s.ran = append(s.ran, id) + return nil, true + } + } + pass := func(id string) func(*state, tea.KeyPressMsg) (tea.Cmd, bool) { + return func(s *state, _ tea.KeyPressMsg) (tea.Cmd, bool) { + s.ran = append(s.ran, id+"-pass") + return nil, false + } + } + + bindings := []KeyBinding[state]{ + {ID: "first", Match: MatchKey(tea.KeyEnter), Handle: pass("first")}, + {ID: "second", Match: MatchKey(tea.KeyEnter), Handle: calls("second")}, + {ID: "third", Match: MatchKey(tea.KeyEnter), Handle: calls("third")}, + } + + var s state + _, ok := Dispatch(&s, bindings, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !ok { + t.Fatal("expected dispatch to claim Enter") + } + if !reflect.DeepEqual(s.ran, []string{"first-pass", "second"}) { + t.Errorf("expected first-pass→second, got %v", s.ran) + } +} + +func TestDispatch_NoMatchReturnsNotClaimed(t *testing.T) { + type state struct{} + bindings := []KeyBinding[state]{ + {ID: "e", Match: MatchKey(tea.KeyEnter), Handle: func(*state, tea.KeyPressMsg) (tea.Cmd, bool) { return nil, true }}, + } + _, ok := Dispatch(&state{}, bindings, tea.KeyPressMsg{Code: tea.KeyEscape}) + if ok { + t.Error("expected unclaimed when no binding matches") + } +} + +func TestDispatch_NilSafeMatchAndHandle(t *testing.T) { + type state struct{} + bindings := []KeyBinding[state]{ + {ID: "broken-match", Handle: func(*state, tea.KeyPressMsg) (tea.Cmd, bool) { return nil, true }}, + {ID: "broken-handle", Match: MatchKey(tea.KeyEnter)}, + {ID: "ok", Match: MatchKey(tea.KeyEnter), Handle: func(*state, tea.KeyPressMsg) (tea.Cmd, bool) { return nil, true }}, + } + _, ok := Dispatch(&state{}, bindings, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !ok { + t.Error("expected dispatch to find a fully-formed binding") + } +} + +func TestHintsFor_FiltersEmptyLabels(t *testing.T) { + type state struct { + filter string + } + bindings := []KeyBinding[state]{ + {ID: "nav", Label: func(*state) string { return "↑/↓ navigate" }}, + {ID: "esc", Label: func(s *state) string { + if s.filter != "" { + return "esc clear filter" + } + return "esc back" + }}, + {ID: "hidden", Label: func(*state) string { return "" }}, + {ID: "no-label-fn"}, // nil Label safely skipped + } + + got := HintsFor(&state{}, bindings) + if !reflect.DeepEqual(got, []string{"↑/↓ navigate", "esc back"}) { + t.Errorf("default state hints = %v", got) + } + got = HintsFor(&state{filter: "a"}, bindings) + if !reflect.DeepEqual(got, []string{"↑/↓ navigate", "esc clear filter"}) { + t.Errorf("filtering state hints = %v", got) + } +} + +func TestApplyBindingOverrides_RelabelAndHide(t *testing.T) { + type state struct{} + defaults := []KeyBinding[state]{ + {ID: "nav", Label: func(*state) string { return "↑/↓ navigate" }}, + {ID: "esc", Label: func(*state) string { return "esc back" }}, + {ID: "exit", Label: func(*state) string { return "ctrl+c exit" }}, + } + + overridden := ApplyBindingOverrides(defaults, map[string]string{"esc": "esc cancel"}, []string{"exit"}) + + hints := HintsFor(&state{}, overridden) + if !reflect.DeepEqual(hints, []string{"↑/↓ navigate", "esc cancel"}) { + t.Errorf("expected relabel + hide, got %v", hints) + } + + // Defaults must not be mutated. + if defaults[1].Label(&state{}) != "esc back" { + t.Error("defaults were mutated in place") + } + if defaults[2].Label(&state{}) != "ctrl+c exit" { + t.Error("hidden binding label was mutated in defaults slice") + } +} + +func TestApplyBindingOverrides_NoopWhenEmpty(t *testing.T) { + type state struct{} + defaults := []KeyBinding[state]{ + {ID: "a", Label: func(*state) string { return "a" }}, + } + got := ApplyBindingOverrides(defaults, nil, nil) + // Identity return is fine; behavior must be equivalent. + if HintsFor(&state{}, got)[0] != "a" { + t.Error("identity passthrough broken") + } +} diff --git a/pkg/tui/bubbletea/keys.go b/pkg/tui/bubbletea/keys.go new file mode 100644 index 0000000..7d3ec88 --- /dev/null +++ b/pkg/tui/bubbletea/keys.go @@ -0,0 +1,29 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +// Used by editor/pager/progress/spinner (msg.String() comparison). +// Prompt models now dispatch via KeyBinding and don't reference these. +const ( + keyCtrlC = "ctrl+c" + keyEsc = "esc" +) + +// Shared across prompt models so the goconst linter stays quiet. +const ( + hintEscBack = "esc back" + hintCtrlCExit = "ctrl+c exit" + hintEnterEntry = "enter submit" +) diff --git a/pkg/tui/bubbletea/list_helpers.go b/pkg/tui/bubbletea/list_helpers.go new file mode 100644 index 0000000..39ce285 --- /dev/null +++ b/pkg/tui/bubbletea/list_helpers.go @@ -0,0 +1,62 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import "strings" + +// visibleWindow computes the visible slice bounds [start, end) for a +// scrolling viewport given total entries, cursor position, and page size. +func visibleWindow(total, cursor, pageSize int) (int, int) { + if total == 0 { + return 0, 0 + } + if pageSize >= total { + return 0, total + } + half := pageSize / 2 + start := cursor - half + if start < 0 { + start = 0 + } + end := start + pageSize + if end > total { + end = total + start = end - pageSize + } + return start, end +} + +// refilter rebuilds matched from choices using filter. With a non-empty +// filter the passed-in slice is truncated and reused to avoid allocation; +// with an empty filter a fresh full-length slice is allocated (every +// index matches, so there is nothing to reuse). Returns the new matched +// slice; caller must reset cursor to 0. +func refilter(filter string, choices []string, matched []int) []int { + if filter == "" { + matched = make([]int, len(choices)) + for i := range choices { + matched[i] = i + } + } else { + lower := strings.ToLower(filter) + matched = matched[:0] + for i, c := range choices { + if strings.Contains(strings.ToLower(c), lower) { + matched = append(matched, i) + } + } + } + return matched +} diff --git a/pkg/tui/bubbletea/live_list.go b/pkg/tui/bubbletea/live_list.go new file mode 100644 index 0000000..f016c04 --- /dev/null +++ b/pkg/tui/bubbletea/live_list.go @@ -0,0 +1,309 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "errors" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// liveListUpdateMsg is the in-program form of LiveListUpdate; the pump +// goroutine in Prompter.LiveList forwards each external update as one. +type liveListUpdateMsg struct { + Key string + Label string + Err error +} + +// liveListModel embeds selectModel for cursor/filter/matched state and +// adds a Key→index map plus a per-row error set. Update, View, and +// Hints are overridden because Go method promotion isn't virtual — +// the embedded selectModel's View would otherwise call its own Hints +// against its own bindings. +type liveListModel struct { + selectModel + keyIndex map[string]int + keys []string // parallel to choices + errs map[int]bool + // shadows selectModel.bindings; this slice drives dispatch + Hints + bindings []KeyBinding[liveListModel] +} + +// DefaultLiveListBindings returns a fresh copy of DefaultSelectBindings +// re-typed for *liveListModel. +func DefaultLiveListBindings() []KeyBinding[liveListModel] { + return adaptSelectBindings(DefaultSelectBindings()) +} + +// adaptSelectBindings re-types each binding for *liveListModel by +// routing Label/Handle through the embedded selectModel. +func adaptSelectBindings(src []KeyBinding[selectModel]) []KeyBinding[liveListModel] { + out := make([]KeyBinding[liveListModel], len(src)) + for i, b := range src { + out[i] = KeyBinding[liveListModel]{ + ID: b.ID, + Match: b.Match, + Label: func(m *liveListModel) string { + if b.Label == nil { + return "" + } + return b.Label(&m.selectModel) + }, + Handle: func(m *liveListModel, msg tea.KeyPressMsg) (tea.Cmd, bool) { + if b.Handle == nil { + return nil, false + } + return b.Handle(&m.selectModel, msg) + }, + } + } + return out +} + +// WithLiveListAddBindings prepends extras so they outrank the defaults. +// See WithSelectAddBindings for semantics. +func WithLiveListAddBindings(extras ...KeyBinding[liveListModel]) tui.LiveListOption { + return func(c *tui.LiveListConfig) { + existing, _ := c.ExtraBindings.([]KeyBinding[liveListModel]) + c.ExtraBindings = append(existing, extras...) + } +} + +func newLiveListModel(prompt string, rows []tui.LiveRow, cfg tui.LiveListConfig) liveListModel { + choices := make([]string, len(rows)) + keys := make([]string, len(rows)) + keyIndex := make(map[string]int, len(rows)) + for i, r := range rows { + choices[i] = r.Label + keys[i] = r.Key + keyIndex[r.Key] = i + } + // Binding overrides are deliberately not passed to the embedded + // selectModel — liveListModel.bindings is the active dispatch set. + sm := newSelectModel(prompt, choices, tui.SelectConfig{ + Default: cfg.Default, + PageSize: cfg.PageSize, + Loop: cfg.Loop, + ShowHints: cfg.ShowHints, + Hints: cfg.Hints, + }) + defaults := ApplyBindingOverrides(DefaultLiveListBindings(), cfg.RelabelByID, cfg.HiddenByID) + var bindings []KeyBinding[liveListModel] + if extras, ok := cfg.ExtraBindings.([]KeyBinding[liveListModel]); ok && len(extras) > 0 { + bindings = make([]KeyBinding[liveListModel], 0, len(extras)+len(defaults)) + bindings = append(bindings, extras...) + bindings = append(bindings, defaults...) + } else { + bindings = defaults + } + return liveListModel{ + selectModel: sm, + keyIndex: keyIndex, + keys: keys, + errs: make(map[int]bool), + bindings: bindings, + } +} + +func (m liveListModel) Init() tea.Cmd { return nil } + +func (m liveListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch v := msg.(type) { + case GoBackMsg: + // Standalone mode quit; wizard composite intercepts before this. + return m, tea.Quit + case liveListUpdateMsg: + if m.chosen { + // Selection is locked in; bubbletea may still drain pumped + // updates before teardown. Applying one here would refilter + // and could empty matched, panicking View's chosen branch. + return m, nil + } + idx, ok := m.keyIndex[v.Key] + if !ok { + return m, nil + } + m.choices[idx] = v.Label + if v.Err != nil { + m.errs[idx] = true + } else { + delete(m.errs, idx) + } + // Preserve cursor by Key across the refilter that follows: the + // row at cursor may move when its label changes. + cursorKey := "" + if len(m.matched) > 0 && m.cursor >= 0 && m.cursor < len(m.matched) { + cursorKey = m.keys[m.matched[m.cursor]] + } + m.refilter() + if cursorKey != "" { + for i, mi := range m.matched { + if m.keys[mi] == cursorKey { + m.cursor = i + break + } + } + } + return m, nil + case tea.KeyPressMsg: + cmd, _ := Dispatch(&m, m.bindings, v) + return m, cmd + } + return m, nil +} + +func (m liveListModel) View() tea.View { + if m.chosen { + selected := m.choices[m.matched[m.cursor]] + return tea.NewView(fmt.Sprintf("%s %s %s\n", promptStyle.Render("?"), titleStyle.Render(m.prompt), answerStyle.Render(selected))) + } + + var b strings.Builder + fmt.Fprintf(&b, "%s %s", promptStyle.Render("?"), titleStyle.Render(m.prompt)) + if m.filter != "" { + fmt.Fprintf(&b, " %s", answerStyle.Render(m.filter)) + } + b.WriteString("\n") + + if len(m.matched) == 0 { + fmt.Fprintf(&b, " %s\n", dimStyle.Render("no matches")) + m.renderLiveHintBar(&b) + return tea.NewView(b.String()) + } + + start, end := m.visibleRange() + for i := start; i < end; i++ { + choiceIdx := m.matched[i] + label := m.choices[choiceIdx] + errored := m.errs[choiceIdx] + switch { + case i == m.cursor && errored: + fmt.Fprintf(&b, " %s %s\n", cursorStyle.Render(">"), errorStyle.Render(label)) + case i == m.cursor: + fmt.Fprintf(&b, " %s %s\n", cursorStyle.Render(">"), selectedStyle.Render(label)) + case errored: + fmt.Fprintf(&b, " %s\n", errorStyle.Render(label)) + default: + fmt.Fprintf(&b, " %s\n", dimStyle.Render(label)) + } + } + m.renderLiveHintBar(&b) + return tea.NewView(b.String()) +} + +// renderLiveHintBar mirrors selectModel.renderHintBar but routes +// through liveListModel.Hints so its own binding overrides apply. +func (m liveListModel) renderLiveHintBar(b *strings.Builder) { + if !m.showHints { + return + } + fmt.Fprintf(b, "\n%s\n", dimStyle.Render(strings.Join(m.Hints(), " · "))) +} + +// Hints reads from liveListModel.bindings (overriding promoted +// selectModel.Hints). customHints (WithLiveListHints) wins. +func (m liveListModel) Hints() []string { + if m.customHints != nil { + return m.customHints + } + return HintsFor(&m, m.bindings) +} + +// Result returns the selected row's original index (not the +// post-filter matched-set position). +func (m liveListModel) Result() (any, bool) { + if !m.chosen || len(m.matched) == 0 { + return nil, false + } + return m.matched[m.cursor], true +} + +// NewLiveListPrompt builds a live-list model for the wizard composite; +// updates must be routed in by the composite as liveListUpdateMsg. +func NewLiveListPrompt(prompt string, rows []tui.LiveRow, cfg tui.LiveListConfig) PromptModel { + return newLiveListModel(prompt, rows, cfg) +} + +// pumpLiveListUpdates forwards updates to send. Exits on ctx done, +// done close (program returned), or updates close. Extracted so each +// branch is unit-testable without driving a real tea.Program. +func pumpLiveListUpdates(ctx context.Context, done <-chan struct{}, updates <-chan tui.LiveListUpdate, send func(liveListUpdateMsg)) { + for { + select { + case <-ctx.Done(): + return + case <-done: + return + case u, ok := <-updates: + if !ok { + return + } + send(liveListUpdateMsg{Key: u.Key, Label: u.Label, Err: u.Err}) + } + } +} + +// LiveList implements tui.LiveLister. +func (p *Prompter) LiveList(ctx context.Context, prompt string, rows []tui.LiveRow, updates <-chan tui.LiveListUpdate, opts ...tui.LiveListOption) (int, error) { + if len(rows) == 0 { + return -1, fmt.Errorf("live list prompt: no rows provided") + } + + cfg := tui.ResolveLiveListConfig(opts) + model := newLiveListModel(prompt, rows, cfg) + + prog := tea.NewProgram(model, + tea.WithInput(p.in), + tea.WithOutput(p.out), + tea.WithContext(ctx), + ) + + // done is closed after prog.Run returns so the pump can't outlive + // the program even when ctx stays alive and updates is never closed. + done := make(chan struct{}) + if updates != nil { + go pumpLiveListUpdates(ctx, done, updates, func(m liveListUpdateMsg) { prog.Send(m) }) + } + + result, err := prog.Run() + close(done) + interrupted := errors.Is(err, tea.ErrInterrupted) + if !interrupted { + if m, ok := result.(liveListModel); ok { + interrupted = m.interrupted + } + } + if interrupted { + return -1, tui.ErrInterrupted + } + if err != nil { + return -1, fmt.Errorf("live list prompt: %w", err) + } + + m := result.(liveListModel) + if m.aborted { + return -1, context.Canceled + } + if !m.chosen || len(m.matched) == 0 { + return -1, context.Canceled + } + return m.matched[m.cursor], nil +} diff --git a/pkg/tui/bubbletea/live_list_test.go b/pkg/tui/bubbletea/live_list_test.go new file mode 100644 index 0000000..ccb59eb --- /dev/null +++ b/pkg/tui/bubbletea/live_list_test.go @@ -0,0 +1,483 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "errors" + "runtime" + "strings" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func runtimeNumGoroutine() int { return runtime.NumGoroutine() } + +// --- helpers --- + +func makeLiveRows(keys ...string) []tui.LiveRow { + out := make([]tui.LiveRow, len(keys)) + for i, k := range keys { + out[i] = tui.LiveRow{Key: k, Label: k + " ..."} + } + return out +} + +func newLL(rows []tui.LiveRow, opts ...tui.LiveListOption) liveListModel { + cfg := tui.ResolveLiveListConfig(opts) + return newLiveListModel("Pick", rows, cfg) +} + +func llKey(m liveListModel, msg tea.KeyPressMsg) liveListModel { + updated, _ := m.Update(msg) + return updated.(liveListModel) +} + +func llRune(m liveListModel, r rune) liveListModel { + return llKey(m, tea.KeyPressMsg{Code: r, Text: string(r)}) +} + +func llSendUpdate(m liveListModel, key, label string, err error) liveListModel { + updated, _ := m.Update(liveListUpdateMsg{Key: key, Label: label, Err: err}) + return updated.(liveListModel) +} + +// --- Spec table tests --- + +// "No updates received" — behaves identically to Select. +func TestLiveList_NoUpdates_BehavesLikeSelect(t *testing.T) { + m := newLL(makeLiveRows("alpha", "beta", "gamma")) + m = llKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = llKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.chosen { + t.Fatal("Enter should choose") + } + if got, _ := m.Result(); got != 1 { + t.Errorf("Result() = %v, want 1 (beta)", got) + } +} + +// "Update before user input" — row label changes in View(). +func TestLiveList_UpdateChangesViewLabel(t *testing.T) { + m := newLL(makeLiveRows("alpha", "beta")) + m = llSendUpdate(m, "alpha", "alpha (running)", nil) + view := m.View().Content + if !strings.Contains(view, "alpha (running)") { + t.Errorf("expected updated label in view, got:\n%s", view) + } + if strings.Contains(view, "alpha ...") { + t.Errorf("placeholder should be replaced, got:\n%s", view) + } +} + +// "Update changes filter membership" — row drops in/out on refilter. +func TestLiveList_UpdateChangesFilterMembership(t *testing.T) { + m := newLL(makeLiveRows("alpha", "beta")) + // Type 'z' (not vim-nav, doesn't appear in placeholders) → empty match. + m = llRune(m, 'z') + if len(m.matched) != 0 { + t.Fatalf("setup: expected 0 matches for filter 'z', got %d", len(m.matched)) + } + // Update alpha to contain 'z'. + m = llSendUpdate(m, "alpha", "alpha (zzz)", nil) + if len(m.matched) != 1 || m.keys[m.matched[0]] != "alpha" { + t.Errorf("alpha should now match filter, got matched=%v", m.matched) + } +} + +// "Update with unknown Key" — silently dropped. +func TestLiveList_UnknownKeyDropped(t *testing.T) { + m := newLL(makeLiveRows("alpha")) + before := m.View().Content + m = llSendUpdate(m, "nonexistent", "ignored", nil) + after := m.View().Content + if before != after { + t.Errorf("unknown key should not change view\nbefore:\n%s\nafter:\n%s", before, after) + } +} + +// "Update with Err set" — row renders with error style; Label visible. +func TestLiveList_ErrorStyleApplied(t *testing.T) { + m := newLL(makeLiveRows("alpha", "beta")) + m = llSendUpdate(m, "beta", "beta: rate limited", errors.New("429")) + + idx := m.keyIndex["beta"] + if !m.errs[idx] { + t.Fatal("expected beta marked as errored") + } + view := m.View().Content + if !strings.Contains(view, "beta: rate limited") { + t.Errorf("error label should still be visible, got:\n%s", view) + } +} + +// A successful follow-up update clears the error flag. +func TestLiveList_ErrorClearedByLaterSuccess(t *testing.T) { + m := newLL(makeLiveRows("alpha")) + m = llSendUpdate(m, "alpha", "alpha: err", errors.New("boom")) + idx := m.keyIndex["alpha"] + if !m.errs[idx] { + t.Fatal("setup: expected errored") + } + m = llSendUpdate(m, "alpha", "alpha (ok)", nil) + if m.errs[idx] { + t.Error("successful update should clear error") + } +} + +// "Multiple updates same Key" — last write wins. +func TestLiveList_LastWriteWins(t *testing.T) { + m := newLL(makeLiveRows("alpha")) + m = llSendUpdate(m, "alpha", "first", nil) + m = llSendUpdate(m, "alpha", "second", nil) + m = llSendUpdate(m, "alpha", "third", nil) + if got := m.choices[0]; got != "third" { + t.Errorf("expected 'third', got %q", got) + } +} + +// "Cursor preservation across update" — cursor stays on hovered key +// even when its filtered index shifts. +func TestLiveList_CursorPreservedByKey(t *testing.T) { + m := newLL(makeLiveRows("alpha", "beta", "gamma")) + // Move cursor to beta. + m = llKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.keys[m.matched[m.cursor]] != "beta" { + t.Fatalf("setup: cursor should be on beta, got %q", m.keys[m.matched[m.cursor]]) + } + + // Now update alpha's label such that it sorts/filters differently. + // (Refilter resets cursor to 0; preservation logic must restore.) + m = llSendUpdate(m, "alpha", "alpha (changed)", nil) + + if got := m.keys[m.matched[m.cursor]]; got != "beta" { + t.Errorf("cursor should remain on beta after unrelated update, got %q", got) + } +} + +// Cursor preservation also works when the update is for the row +// currently under the cursor. +func TestLiveList_CursorStaysOnSelfUpdate(t *testing.T) { + m := newLL(makeLiveRows("alpha", "beta")) + m = llKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) // hover beta + m = llSendUpdate(m, "beta", "beta (running)", nil) + if got := m.keys[m.matched[m.cursor]]; got != "beta" { + t.Errorf("cursor should stay on beta, got %q", got) + } +} + +// "Selection works mid-update" — Enter still returns the cursor row. +func TestLiveList_SelectMidUpdate(t *testing.T) { + m := newLL(makeLiveRows("alpha", "beta", "gamma")) + m = llKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) // beta + m = llSendUpdate(m, "alpha", "alpha (updated)", nil) + m = llKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.chosen { + t.Fatal("Enter should choose") + } + idx, _ := m.Result() + if idx != 1 { + t.Errorf("Result() = %v, want 1 (beta)", idx) + } +} + +// "Ctrl+C / Esc" — same cancel semantics as Select. +func TestLiveList_EscAndCtrlC(t *testing.T) { + // Esc. + m := newLL(makeLiveRows("a")) + m = llKey(m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if !m.aborted { + t.Error("Esc should set aborted") + } + // Feeding back the GoBackMsg should quit. + _, cmd := m.Update(GoBackMsg{}) + if cmd == nil { + t.Fatal("GoBackMsg should produce a quit cmd") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", cmd()) + } + + // Ctrl+C. + m = newLL(makeLiveRows("a")) + m = llKey(m, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if !m.interrupted { + t.Error("ctrl+c should set interrupted") + } +} + +// "WithShowHints(true)" — hint bar renders below choices. +func TestLiveList_ShowHintsRendersBar(t *testing.T) { + m := newLL(makeLiveRows("alpha"), tui.WithLiveListShowHints(true)) + view := m.View().Content + if !strings.Contains(view, "↑/↓ navigate") { + t.Errorf("expected default hint bar, got:\n%s", view) + } + if !strings.Contains(view, "ctrl+c exit") { + t.Errorf("expected ctrl+c exit in hints, got:\n%s", view) + } +} + +// "WithLiveListRelabel" — hint bar shows the relabeled string. +func TestLiveList_RelabelAppearsInHints(t *testing.T) { + m := newLL(makeLiveRows("alpha"), + tui.WithLiveListShowHints(true), + tui.WithLiveListRelabel("esc", "esc abort"), + ) + view := m.View().Content + if !strings.Contains(view, "esc abort") { + t.Errorf("expected relabeled esc, got:\n%s", view) + } + if strings.Contains(view, "esc back") { + t.Errorf("default esc label should be replaced, got:\n%s", view) + } +} + +// "WithLiveListAddBindings" — custom binding dispatches; appears in +// hint bar. +func TestLiveList_AddBindings(t *testing.T) { + fired := false + refresh := KeyBinding[liveListModel]{ + ID: "refresh", + Match: MatchRune('r', tea.ModCtrl), + Label: func(*liveListModel) string { return "ctrl+r refresh" }, + Handle: func(_ *liveListModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + fired = true + return nil, true + }, + } + m := newLL(makeLiveRows("alpha"), + tui.WithLiveListShowHints(true), + WithLiveListAddBindings(refresh), + ) + view := m.View().Content + if !strings.Contains(view, "ctrl+r refresh") { + t.Errorf("custom binding label missing, got:\n%s", view) + } + _ = llKey(m, tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) + if !fired { + t.Error("custom binding did not fire") + } +} + +// Hide path: ID hidden suppresses the label, key handling preserved. +func TestLiveList_HideSuppressesLabel(t *testing.T) { + m := newLL(makeLiveRows("alpha"), + tui.WithLiveListShowHints(true), + tui.WithLiveListHide("exit"), + ) + view := m.View().Content + if strings.Contains(view, "ctrl+c exit") { + t.Errorf("exit hint should be hidden, got:\n%s", view) + } + m = llKey(m, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if !m.interrupted { + t.Error("ctrl+c should still trigger interrupt after hide") + } +} + +// Default hint sequence matches Select's — locks compat with the +// existing select hint string consumers may assert against. +func TestLiveList_DefaultHintsOrder(t *testing.T) { + m := newLL(makeLiveRows("alpha")) + got := strings.Join(m.Hints(), " · ") + want := "↑/↓ navigate · type to filter · enter select · esc back · ctrl+c exit" + if got != want { + t.Errorf("hint sequence drifted\n got: %s\nwant: %s", got, want) + } +} + +// A late update that arrives after Enter (chosen) must not panic in +// View, even when it would narrow the active filter to zero matches. +// bubbletea drains queued msgs after tea.Quit, so a pumped update can +// land between Enter and teardown. +func TestLiveList_NoPanicOnUpdateAfterChosen(t *testing.T) { + m := newLL(makeLiveRows("alpha", "beta")) + // Filter down to just the alpha row. + for _, r := range "alpha" { + m = llRune(m, r) + } + if len(m.matched) != 1 { + t.Fatalf("setup: expected 1 match for filter 'alpha', got %d", len(m.matched)) + } + m = llKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.chosen { + t.Fatal("setup: Enter should choose") + } + // Late update renames alpha so the active filter "alpha" no longer + // matches it → refilter would empty matched. + m = llSendUpdate(m, "alpha", "zzz", nil) + // Must not panic. + view := m.View().Content + if view == "" { + t.Fatal("expected non-empty view after chosen") + } +} + +// --- Prompter-level (full integration) tests --- + +// "Channel closed before selection" — picker still selectable until +// user acts. Uses Prompter.LiveList with a manually-driven program +// would be heavyweight; we test the equivalent at the model level: +// after the update stream is exhausted, Update on KeyEnter still +// completes the prompt. +func TestLiveList_ChannelClosedStillSelectable(t *testing.T) { + m := newLL(makeLiveRows("alpha", "beta")) + // Simulate a stream that delivered then would close. + m = llSendUpdate(m, "alpha", "alpha (running)", nil) + m = llSendUpdate(m, "beta", "beta (running)", nil) + m = llKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.chosen { + t.Error("should still be selectable after stream exhausted") + } +} + +// Pump exits via the done channel when the bubbletea program returns, +// even if ctx stays alive and the caller never closes updates. This is +// the leak-prevention guarantee — exercised on the helper directly so +// it doesn't depend on driving a real tea.Program. +func TestLiveList_PumpExitsOnDoneClose(t *testing.T) { + updates := make(chan tui.LiveListUpdate) // never closed + done := make(chan struct{}) + exited := make(chan struct{}) + + go func() { + pumpLiveListUpdates(context.Background(), done, updates, func(liveListUpdateMsg) {}) + close(exited) + }() + + close(done) + + select { + case <-exited: + case <-time.After(time.Second): + t.Fatal("pump did not exit after done close") + } +} + +// Pump exits via ctx.Done when ctx is canceled with the program still +// running and updates still open. +func TestLiveList_PumpExitsOnCtxDone(t *testing.T) { + updates := make(chan tui.LiveListUpdate) + done := make(chan struct{}) + ctx, cancel := context.WithCancel(context.Background()) + exited := make(chan struct{}) + + go func() { + pumpLiveListUpdates(ctx, done, updates, func(liveListUpdateMsg) {}) + close(exited) + }() + + cancel() + + select { + case <-exited: + case <-time.After(time.Second): + t.Fatal("pump did not exit after ctx cancel") + } +} + +// Pump exits when the updates channel is closed. +func TestLiveList_PumpExitsOnUpdatesClose(t *testing.T) { + updates := make(chan tui.LiveListUpdate) + done := make(chan struct{}) + exited := make(chan struct{}) + + go func() { + pumpLiveListUpdates(context.Background(), done, updates, func(liveListUpdateMsg) {}) + close(exited) + }() + + close(updates) + + select { + case <-exited: + case <-time.After(time.Second): + t.Fatal("pump did not exit after updates close") + } +} + +// Updates flowing through the pump reach the supplied send fn. +func TestLiveList_PumpForwardsUpdates(t *testing.T) { + updates := make(chan tui.LiveListUpdate, 2) + done := make(chan struct{}) + defer close(done) + + got := make(chan liveListUpdateMsg, 2) + go pumpLiveListUpdates(context.Background(), done, updates, func(m liveListUpdateMsg) { + got <- m + }) + + updates <- tui.LiveListUpdate{Key: "k1", Label: "L1"} + updates <- tui.LiveListUpdate{Key: "k2", Label: "L2", Err: errors.New("e")} + + for i, want := range []liveListUpdateMsg{ + {Key: "k1", Label: "L1"}, + {Key: "k2", Label: "L2", Err: errors.New("e")}, + } { + select { + case m := <-got: + if m.Key != want.Key || m.Label != want.Label { + t.Errorf("update %d = %+v, want %+v", i, m, want) + } + if (m.Err == nil) != (want.Err == nil) { + t.Errorf("update %d err presence mismatch", i) + } + case <-time.After(time.Second): + t.Fatalf("update %d not forwarded", i) + } + } +} + +// Full integration check: LiveList returns when ctx is canceled, and +// the call doesn't leak goroutines. Does not strictly prove the +// done-close branch (impossible without a TTY to drive Enter) — the +// branch-specific guarantees are covered by TestLiveList_PumpExitsOnDoneClose. +func TestLiveList_ReturnsAfterCtxCancel(t *testing.T) { + p := New() + rows := makeLiveRows("alpha", "beta") + updates := make(chan tui.LiveListUpdate) // never written, never closed + + pre := runtimeNumGoroutine() + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + wg.Go(func() { + _, _ = p.LiveList(ctx, "Pick", rows, updates) + }) + time.Sleep(50 * time.Millisecond) + cancel() + + returned := make(chan struct{}) + go func() { wg.Wait(); close(returned) }() + select { + case <-returned: + case <-time.After(2 * time.Second): + t.Fatal("LiveList did not return after ctx cancel") + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if runtimeNumGoroutine() <= pre+1 { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Errorf("goroutine leak: pre=%d post=%d", pre, runtimeNumGoroutine()) +} diff --git a/pkg/tui/bubbletea/multiselect.go b/pkg/tui/bubbletea/multiselect.go new file mode 100644 index 0000000..7fd832a --- /dev/null +++ b/pkg/tui/bubbletea/multiselect.go @@ -0,0 +1,433 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +type multiSelectModel struct { + prompt string + choices []string + filter string + matched []int // indices into choices that match filter + cursor int // position within matched (not choices) + selected map[int]bool + pageSize int + loop bool + min int + max int + minErr func(min int) string // resolved: caller override or library default + maxErr func(max int) string // resolved: caller override or library default + showHints bool + customHints []string // nil = derive from bindings + bindings []KeyBinding[multiSelectModel] // resolved (defaults + overrides + extras) + done bool + aborted bool + interrupted bool // true for Ctrl+C (hard cancel), false for Esc (soft cancel) + err string +} + +// DefaultMultiSelectBindings returns a fresh copy of the canonical +// binding set. Stable IDs for WithMultiSelectRelabel / Hide: navigate, +// vim-up, vim-down, toggle, select-all, filter-type, confirm, esc, +// filter-backspace, exit. +func DefaultMultiSelectBindings() []KeyBinding[multiSelectModel] { + return []KeyBinding[multiSelectModel]{ + { + ID: "navigate", + Match: MatchKey(tea.KeyUp, tea.KeyDown), + Label: func(*multiSelectModel) string { return "↑/↓ navigate" }, + Handle: func(m *multiSelectModel, msg tea.KeyPressMsg) (tea.Cmd, bool) { + if msg.Code == tea.KeyUp { + m.moveUp() + } else { + m.moveDown() + } + return nil, true + }, + }, + { + ID: "vim-up", + Match: MatchRune('k'), + Label: func(*multiSelectModel) string { return "" }, + // j/k navigate when filter is empty; otherwise pass through + // so filter-type appends the key to the filter. + Handle: func(m *multiSelectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if m.filter != "" { + return nil, false + } + m.moveUp() + return nil, true + }, + }, + { + ID: "vim-down", + Match: MatchRune('j'), + Label: func(*multiSelectModel) string { return "" }, + Handle: func(m *multiSelectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if m.filter != "" { + return nil, false + } + m.moveDown() + return nil, true + }, + }, + { + ID: "toggle", + Match: MatchKey(tea.KeySpace), + Label: func(*multiSelectModel) string { return "space toggle" }, + Handle: func(m *multiSelectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if len(m.matched) == 0 { + return nil, true + } + idx := m.matched[m.cursor] + if m.selected[idx] { + delete(m.selected, idx) + m.err = "" + } else { + if m.max > 0 && len(m.selected) >= m.max { + m.err = m.maxErr(m.max) + } else { + m.selected[idx] = true + m.err = "" + } + } + return nil, true + }, + }, + { + ID: "select-all", + Match: MatchRune('a', tea.ModCtrl), + Label: func(*multiSelectModel) string { return "ctrl+a select all" }, + Handle: func(m *multiSelectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.toggleAll() + return nil, true + }, + }, + { + ID: "filter-type", + Match: MatchText(), + Label: func(*multiSelectModel) string { return "type to filter" }, + Handle: func(m *multiSelectModel, msg tea.KeyPressMsg) (tea.Cmd, bool) { + m.filter += msg.Text + m.refilter() + return nil, true + }, + }, + { + ID: "confirm", + Match: MatchKey(tea.KeyEnter), + Label: func(*multiSelectModel) string { return "enter confirm" }, + Handle: func(m *multiSelectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if m.min > 0 && len(m.selected) < m.min { + m.err = m.minErr(m.min) + return nil, true + } + m.done = true + return tea.Quit, true + }, + }, + { + ID: "esc", + Match: MatchKey(tea.KeyEscape), + Label: func(m *multiSelectModel) string { + if m.filter != "" { + return "esc clear filter" + } + return hintEscBack + }, + Handle: func(m *multiSelectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if m.filter != "" { + m.filter = "" + m.refilter() + return nil, true + } + m.aborted = true + return func() tea.Msg { return GoBackMsg{} }, true + }, + }, + { + ID: "filter-backspace", + Match: MatchKey(tea.KeyBackspace), + Label: func(*multiSelectModel) string { return "" }, + Handle: func(m *multiSelectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if len(m.filter) > 0 { + _, size := utf8.DecodeLastRuneInString(m.filter) + m.filter = m.filter[:len(m.filter)-size] + m.refilter() + } + return nil, true + }, + }, + { + ID: "exit", + Match: MatchRune('c', tea.ModCtrl), + Label: func(*multiSelectModel) string { return hintCtrlCExit }, + Handle: func(m *multiSelectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.interrupted = true + return tea.Quit, true + }, + }, + } +} + +// WithMultiSelectAddBindings prepends extras so they outrank the default +// catch-all matchers. See WithSelectAddBindings for full semantics. +func WithMultiSelectAddBindings(extras ...KeyBinding[multiSelectModel]) tui.MultiSelectOption { + return func(c *tui.MultiSelectConfig) { + existing, _ := c.ExtraBindings.([]KeyBinding[multiSelectModel]) + c.ExtraBindings = append(existing, extras...) + } +} + +func newMultiSelectModel(prompt string, choices []string, cfg tui.MultiSelectConfig) multiSelectModel { + ps := cfg.PageSize + if ps <= 0 || ps > len(choices) { + ps = len(choices) + } + selected := make(map[int]bool) + for _, idx := range cfg.Defaults { + if idx >= 0 && idx < len(choices) { + selected[idx] = true + } + } + matched := make([]int, len(choices)) + for i := range choices { + matched[i] = i + } + minErr := cfg.MinError + if minErr == nil { + minErr = func(min int) string { + return fmt.Sprintf("at least %d selections required — press space to select", min) + } + } + maxErr := cfg.MaxError + if maxErr == nil { + maxErr = func(max int) string { + return fmt.Sprintf("maximum %d selections allowed", max) + } + } + defaults := ApplyBindingOverrides(DefaultMultiSelectBindings(), cfg.RelabelByID, cfg.HiddenByID) + var bindings []KeyBinding[multiSelectModel] + if extras, ok := cfg.ExtraBindings.([]KeyBinding[multiSelectModel]); ok && len(extras) > 0 { + bindings = make([]KeyBinding[multiSelectModel], 0, len(extras)+len(defaults)) + bindings = append(bindings, extras...) + bindings = append(bindings, defaults...) + } else { + bindings = defaults + } + return multiSelectModel{ + prompt: prompt, + choices: choices, + matched: matched, + selected: selected, + pageSize: ps, + loop: cfg.Loop, + min: cfg.Min, + max: cfg.Max, + minErr: minErr, + maxErr: maxErr, + showHints: cfg.ShowHints, + customHints: cfg.Hints, + bindings: bindings, + } +} + +func (m *multiSelectModel) refilter() { + m.matched = refilter(m.filter, m.choices, m.matched) + m.cursor = 0 +} + +func (m *multiSelectModel) moveUp() { + if len(m.matched) == 0 { + return + } + if m.cursor > 0 { + m.cursor-- + } else if m.loop { + m.cursor = len(m.matched) - 1 + } +} + +func (m *multiSelectModel) moveDown() { + if len(m.matched) == 0 { + return + } + if m.cursor < len(m.matched)-1 { + m.cursor++ + } else if m.loop { + m.cursor = 0 + } +} + +// toggleAll selects or deselects all matched (visible) items. +func (m *multiSelectModel) toggleAll() { + allSelected := true + for _, idx := range m.matched { + if !m.selected[idx] { + allSelected = false + break + } + } + if allSelected { + for _, idx := range m.matched { + delete(m.selected, idx) + } + } else { + for _, idx := range m.matched { + if m.max > 0 && len(m.selected) >= m.max { + m.err = m.maxErr(m.max) + return + } + m.selected[idx] = true + } + } + m.err = "" +} + +func (m multiSelectModel) Init() tea.Cmd { return nil } + +func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(GoBackMsg); ok { + return m, tea.Quit // standalone mode quit; wizard composite intercepts before this + } + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + cmd, _ := Dispatch(&m, m.bindings, key) + return m, cmd +} + +// Hints returns the hint bar entries. customHints (from WithMultiSelectHints) +// wins; otherwise hints derive from the binding set via HintsFor. +func (m multiSelectModel) Hints() []string { + if m.customHints != nil { + return m.customHints + } + return HintsFor(&m, m.bindings) +} + +// Result returns the selected indices after the user confirms. +func (m multiSelectModel) Result() (any, bool) { + if !m.done { + return nil, false + } + var indices []int + for i := range m.choices { + if m.selected[i] { + indices = append(indices, i) + } + } + return indices, true +} + +// NewMultiSelectPrompt creates a multiselect prompt model for use in the wizard composite. +func NewMultiSelectPrompt(prompt string, choices []string, cfg tui.MultiSelectConfig) PromptModel { + return newMultiSelectModel(prompt, choices, cfg) +} + +func (m multiSelectModel) View() tea.View { + if m.done { + var names []string + for i, c := range m.choices { + if m.selected[i] { + names = append(names, c) + } + } + return tea.NewView(fmt.Sprintf("%s %s %s\n", promptStyle.Render("?"), titleStyle.Render(m.prompt), answerStyle.Render(strings.Join(names, ", ")))) + } + + var b strings.Builder + fmt.Fprintf(&b, "%s %s", promptStyle.Render("?"), titleStyle.Render(m.prompt)) + if m.filter != "" { + fmt.Fprintf(&b, " %s", answerStyle.Render(m.filter)) + } + b.WriteString("\n") + + if len(m.matched) == 0 { + fmt.Fprintf(&b, " %s\n", dimStyle.Render("no matches")) + } else { + start, end := m.visibleRange() + for i := start; i < end; i++ { + idx := m.matched[i] + cur := " " + if i == m.cursor { + cur = cursorStyle.Render("> ") + } + check := uncheckStyle.Render("[ ]") + label := dimStyle.Render(m.choices[idx]) + if m.selected[idx] { + check = checkStyle.Render("[x]") + label = selectedStyle.Render(m.choices[idx]) + } + if i == m.cursor && !m.selected[idx] { + label = selectedStyle.Render(m.choices[idx]) + } + fmt.Fprintf(&b, " %s%s %s\n", cur, check, label) + } + } + + if m.err != "" { + fmt.Fprintf(&b, " %s\n", errorStyle.Render("✗ "+m.err)) + } + if m.showHints { + fmt.Fprintf(&b, "\n%s\n", dimStyle.Render(strings.Join(m.Hints(), " · "))) + } + return tea.NewView(b.String()) +} + +func (m multiSelectModel) visibleRange() (int, int) { + return visibleWindow(len(m.matched), m.cursor, m.pageSize) +} + +// MultiSelect implements tui.Prompter. +func (p *Prompter) MultiSelect(ctx context.Context, prompt string, choices []string, opts ...tui.MultiSelectOption) ([]int, error) { + if len(choices) == 0 { + return nil, fmt.Errorf("multi-select prompt: no choices provided") + } + + cfg := tui.ResolveMultiSelectConfig(opts) + model := newMultiSelectModel(prompt, choices, cfg) + + r := p.runProgram(ctx, model) + if r.interrupted { + return nil, tui.ErrInterrupted + } + if r.err != nil { + return nil, fmt.Errorf("multi-select prompt: %w", r.err) + } + + m := r.model.(multiSelectModel) + if m.aborted { + return nil, context.Canceled + } + + var indices []int + for i := range m.choices { + if m.selected[i] { + indices = append(indices, i) + } + } + return indices, nil +} diff --git a/pkg/tui/bubbletea/multiselect_bindings_test.go b/pkg/tui/bubbletea/multiselect_bindings_test.go new file mode 100644 index 0000000..15d98ad --- /dev/null +++ b/pkg/tui/bubbletea/multiselect_bindings_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func TestMultiSelectBindings_DynamicEscLabel(t *testing.T) { + m := newMS([]string{"alpha", "beta"}, func(c *tui.MultiSelectConfig) { c.ShowHints = true }) + + if !containsString(m.Hints(), "esc back") { + t.Errorf("expected 'esc back' before filtering, got %v", m.Hints()) + } + m = msRune(m, 'a') + if !containsString(m.Hints(), "esc clear filter") { + t.Errorf("expected 'esc clear filter' while filtering, got %v", m.Hints()) + } + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyEscape}) + if !containsString(m.Hints(), "esc back") { + t.Errorf("expected 'esc back' after filter cleared, got %v", m.Hints()) + } +} + +func TestMultiSelectBindings_WithMultiSelectRelabel(t *testing.T) { + cfg := tui.ResolveMultiSelectConfig([]tui.MultiSelectOption{ + tui.WithMultiSelectShowHints(true), + tui.WithMultiSelectRelabel("toggle", "␣ pick"), + tui.WithMultiSelectRelabel("confirm", "↵ done"), + }) + m := newMultiSelectModel("Pick", []string{"a"}, cfg) + + hints := m.Hints() + if !containsString(hints, "␣ pick") || !containsString(hints, "↵ done") { + t.Errorf("expected relabels, got %v", hints) + } + if containsString(hints, "space toggle") || containsString(hints, "enter confirm") { + t.Errorf("relabels did not replace defaults, got %v", hints) + } +} + +func TestMultiSelectBindings_WithMultiSelectHide(t *testing.T) { + cfg := tui.ResolveMultiSelectConfig([]tui.MultiSelectOption{ + tui.WithMultiSelectShowHints(true), + tui.WithMultiSelectHide("exit", "select-all"), + }) + m := newMultiSelectModel("Pick", []string{"a"}, cfg) + + hints := m.Hints() + if containsString(hints, "ctrl+c exit") || containsString(hints, "ctrl+a select all") { + t.Errorf("hidden entries leaked into hints, got %v", hints) + } + + // Ctrl+A still toggles all (key handling preserved). + m = msCtrl(m, 'a') + if !m.selected[0] { + t.Error("ctrl+a should still select-all even when label hidden") + } +} + +func TestMultiSelectBindings_WithMultiSelectAddBindings(t *testing.T) { + helped := false + help := KeyBinding[multiSelectModel]{ + ID: "help", + Match: MatchRune('?'), + Label: func(*multiSelectModel) string { return "? help" }, + Handle: func(_ *multiSelectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + helped = true + return nil, true + }, + } + cfg := tui.ResolveMultiSelectConfig([]tui.MultiSelectOption{ + tui.WithMultiSelectShowHints(true), + WithMultiSelectAddBindings(help), + }) + m := newMultiSelectModel("Pick", []string{"a"}, cfg) + + if !containsString(m.Hints(), "? help") { + t.Errorf("custom binding label missing, got %v", m.Hints()) + } + m = msRune(m, '?') + if !helped { + t.Error("custom binding handler did not fire") + } + if m.filter == "?" { + t.Error("custom binding should have stopped before filter-type") + } +} + +func TestMultiSelectBindings_DefaultHintsOrder(t *testing.T) { + cfg := tui.ResolveMultiSelectConfig(nil) + m := newMultiSelectModel("Pick", []string{"a"}, cfg) + got := strings.Join(m.Hints(), " · ") + want := "↑/↓ navigate · space toggle · ctrl+a select all · type to filter · enter confirm · esc back · ctrl+c exit" + if got != want { + t.Errorf("default hint sequence drifted\n got: %s\nwant: %s", got, want) + } +} diff --git a/pkg/tui/bubbletea/multiselect_prompt_test.go b/pkg/tui/bubbletea/multiselect_prompt_test.go new file mode 100644 index 0000000..6912dd9 --- /dev/null +++ b/pkg/tui/bubbletea/multiselect_prompt_test.go @@ -0,0 +1,144 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func TestMultiSelectPrompt_Hints(t *testing.T) { + cfg := tui.ResolveMultiSelectConfig(nil) + m := NewMultiSelectPrompt("Pick", []string{"a", "b"}, cfg) + hints := m.Hints() + if len(hints) == 0 { + t.Fatal("expected hints") + } +} + +func TestMultiSelectPrompt_SpaceAndEnter(t *testing.T) { + cfg := tui.ResolveMultiSelectConfig(nil) + m := NewMultiSelectPrompt("Pick", []string{"alpha", "beta", "gamma"}, cfg) + + // Space to toggle first item + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) + m = updated.(PromptModel) + + // Down + Space to toggle second + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = updated.(PromptModel) + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) + m = updated.(PromptModel) + + // Enter to confirm + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(PromptModel) + + val, done := m.Result() + if !done { + t.Fatal("expected done after Enter") + } + indices, ok := val.([]int) + if !ok { + t.Fatalf("expected []int, got %T", val) + } + if len(indices) != 2 || indices[0] != 0 || indices[1] != 1 { + t.Errorf("expected [0 1], got %v", indices) + } +} + +func TestMultiSelectPrompt_EscGoBack(t *testing.T) { + cfg := tui.ResolveMultiSelectConfig(nil) + m := NewMultiSelectPrompt("Pick", []string{"a"}, cfg) + + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil { + t.Fatal("expected command from Esc") + } + msg := cmd() + if _, ok := msg.(GoBackMsg); !ok { + t.Fatalf("expected GoBackMsg, got %T", msg) + } +} + +func TestMultiSelectPrompt_Result_NotDoneInitially(t *testing.T) { + cfg := tui.ResolveMultiSelectConfig(nil) + m := NewMultiSelectPrompt("Pick", []string{"a"}, cfg) + _, done := m.Result() + if done { + t.Fatal("should not be done initially") + } +} + +func TestMultiSelectPrompt_WithShowHints_PlumbingE2E(t *testing.T) { + opts := []tui.MultiSelectOption{tui.WithMultiSelectShowHints(true)} + cfg := tui.ResolveMultiSelectConfig(opts) + if !cfg.ShowHints { + t.Fatal("WithMultiSelectShowHints did not flow into config") + } + + m := NewMultiSelectPrompt("Pick", []string{"a", "b"}, cfg) + view := m.View().Content + if !strings.Contains(view, "↑/↓ navigate") { + t.Errorf("hint bar missing, got:\n%s", view) + } + if !strings.Contains(view, "ctrl+c exit") { + t.Errorf("default Hints() should include ctrl+c exit, got:\n%s", view) + } +} + +func TestMultiSelectPrompt_WithMultiSelectHints_OverridePlumbingE2E(t *testing.T) { + custom := []string{"↑/↓ move", "␣ check", "↵ done"} + opts := []tui.MultiSelectOption{ + tui.WithMultiSelectShowHints(true), + tui.WithMultiSelectHints(custom...), + } + cfg := tui.ResolveMultiSelectConfig(opts) + + m := NewMultiSelectPrompt("Pick", []string{"a", "b"}, cfg) + hints := m.Hints() + if len(hints) != len(custom) { + t.Fatalf("Hints() length = %d, want %d", len(hints), len(custom)) + } + for i := range custom { + if hints[i] != custom[i] { + t.Errorf("Hints()[%d] = %q, want %q", i, hints[i], custom[i]) + } + } + view := m.View().Content + if !strings.Contains(view, "↑/↓ move · ␣ check · ↵ done") { + t.Errorf("override not rendered, got:\n%s", view) + } + if strings.Contains(view, "ctrl+a select all") { + t.Errorf("default hint text should be replaced by override, got:\n%s", view) + } +} + +func TestMultiSelectPrompt_WithMultiSelectHints_ZeroArgsFallsBackToDefaults(t *testing.T) { + opts := []tui.MultiSelectOption{ + tui.WithMultiSelectShowHints(true), + tui.WithMultiSelectHints(), + } + cfg := tui.ResolveMultiSelectConfig(opts) + m := NewMultiSelectPrompt("Pick", []string{"a"}, cfg) + hints := m.Hints() + if len(hints) == 0 || hints[0] != "↑/↓ navigate" { + t.Errorf("expected defaults, got %v", hints) + } +} diff --git a/pkg/tui/bubbletea/multiselect_test.go b/pkg/tui/bubbletea/multiselect_test.go new file mode 100644 index 0000000..7ae539c --- /dev/null +++ b/pkg/tui/bubbletea/multiselect_test.go @@ -0,0 +1,750 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// --- helpers --- + +func msKey(m multiSelectModel, msg tea.KeyPressMsg) multiSelectModel { + result, _ := m.Update(msg) + return result.(multiSelectModel) +} + +func msRune(m multiSelectModel, r rune) multiSelectModel { + return msKey(m, tea.KeyPressMsg{Code: r, Text: string(r)}) +} + +func msCtrl(m multiSelectModel, code rune) multiSelectModel { + return msKey(m, tea.KeyPressMsg{Code: code, Mod: tea.ModCtrl}) +} + +func newMS(choices []string, opts ...func(*tui.MultiSelectConfig)) multiSelectModel { + cfg := tui.MultiSelectConfig{PageSize: len(choices), Loop: true} + for _, o := range opts { + o(&cfg) + } + return newMultiSelectModel("Pick", choices, cfg) +} + +// ============================================================ +// Filter tests +// ============================================================ + +func TestMultiSelectModel_InitialFilterState(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}) + + if m.filter != "" { + t.Errorf("expected empty filter, got %q", m.filter) + } + if len(m.matched) != 3 { + t.Errorf("expected 3 matched, got %d", len(m.matched)) + } + for i, idx := range m.matched { + if idx != i { + t.Errorf("matched[%d] = %d, want %d", i, idx, i) + } + } +} + +func TestMultiSelectModel_Refilter(t *testing.T) { + m := newMS([]string{"Apple", "Apricot", "Banana", "Blueberry", "Cherry"}) + + m.filter = "ap" + m.refilter() + + if len(m.matched) != 2 { + t.Fatalf("expected 2 matches, got %d", len(m.matched)) + } + if m.matched[0] != 0 || m.matched[1] != 1 { + t.Errorf("matched = %v, want [0 1]", m.matched) + } + if m.cursor != 0 { + t.Errorf("cursor should reset to 0, got %d", m.cursor) + } +} + +func TestMultiSelectModel_RefilterNoMatch(t *testing.T) { + m := newMS([]string{"Apple", "Banana"}) + + m.filter = "zzz" + m.refilter() + + if len(m.matched) != 0 { + t.Errorf("expected 0 matches, got %d", len(m.matched)) + } +} + +func TestMultiSelectModel_RefilterClearRestoresAll(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}) + + m.filter = "ap" + m.refilter() + m.filter = "" + m.refilter() + + if len(m.matched) != 3 { + t.Errorf("expected all 3 restored, got %d", len(m.matched)) + } +} + +func TestMultiSelectModel_TypeToFilter(t *testing.T) { + m := newMS([]string{"Apple", "Apricot", "Banana", "Blueberry"}) + + m = msRune(m, 'b') + + if m.filter != "b" { + t.Errorf("filter = %q, want %q", m.filter, "b") + } + if len(m.matched) != 2 { + t.Errorf("expected 2 matches, got %d", len(m.matched)) + } +} + +func TestMultiSelectModel_Backspace(t *testing.T) { + m := newMS([]string{"Apple", "Apricot", "Banana"}) + + m = msRune(m, 'a') + m = msRune(m, 'p') + + if m.filter != "ap" { + t.Fatalf("filter = %q, want %q", m.filter, "ap") + } + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}) + + if m.filter != "a" { + t.Errorf("filter after backspace = %q, want %q", m.filter, "a") + } + if len(m.matched) != 3 { + t.Errorf("expected 3 matches after backspace, got %d", len(m.matched)) + } +} + +func TestMultiSelectModel_BackspaceOnEmptyIsNoop(t *testing.T) { + m := newMS([]string{"Apple", "Banana"}) + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}) + + if m.filter != "" { + t.Errorf("filter should still be empty, got %q", m.filter) + } + if len(m.matched) != 2 { + t.Errorf("expected 2 matched, got %d", len(m.matched)) + } +} + +func TestMultiSelectModel_BackspaceUnicode(t *testing.T) { + m := newMS([]string{"東京タワー", "大阪城", "富士山"}) + + m = msRune(m, '東') + m = msRune(m, '京') + + if m.filter != "東京" { + t.Fatalf("filter = %q, want '東京'", m.filter) + } + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}) + + if m.filter != "東" { + t.Errorf("filter after backspace = %q, want '東'", m.filter) + } +} + +func TestMultiSelectModel_EscClearsFilterThenAborts(t *testing.T) { + m := newMS([]string{"Apple", "Banana"}) + + m = msRune(m, 'a') + + // First Esc — clears filter + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyEscape}) + + if m.filter != "" { + t.Errorf("filter should be cleared, got %q", m.filter) + } + if m.aborted { + t.Error("should not be aborted after first Esc") + } + if len(m.matched) != 2 { + t.Errorf("all choices should be restored, got %d", len(m.matched)) + } + + // Second Esc — aborts + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyEscape}) + + if !m.aborted { + t.Error("should be aborted after second Esc") + } +} + +// ============================================================ +// Ctrl+A select all tests +// ============================================================ + +func TestMultiSelectModel_CtrlASelectsAll(t *testing.T) { + choices := []string{"Apple", "Banana", "Cherry"} + m := newMS(choices) + + m = msCtrl(m, 'a') + + for i := range choices { + if !m.selected[i] { + t.Errorf("expected choice %d (%s) to be selected", i, choices[i]) + } + } +} + +func TestMultiSelectModel_CtrlATogglesOff(t *testing.T) { + choices := []string{"Apple", "Banana", "Cherry"} + m := newMS(choices) + + // Select all + m = msCtrl(m, 'a') + // Deselect all + m = msCtrl(m, 'a') + + if len(m.selected) != 0 { + t.Errorf("expected 0 selected after toggle off, got %d", len(m.selected)) + } +} + +func TestMultiSelectModel_CtrlASelectsOnlyVisible(t *testing.T) { + choices := []string{"Apple", "Apricot", "Banana", "Blueberry"} + m := newMS(choices) + + // Filter to "ap" → Apple(0), Apricot(1) + m = msRune(m, 'a') + m = msRune(m, 'p') + + if len(m.matched) != 2 { + t.Fatalf("expected 2 matches, got %d", len(m.matched)) + } + + m = msCtrl(m, 'a') + + if !m.selected[0] || !m.selected[1] { + t.Error("Apple and Apricot should be selected") + } + if m.selected[2] || m.selected[3] { + t.Error("Banana and Blueberry should NOT be selected") + } +} + +func TestMultiSelectModel_CtrlATogglesOffOnlyVisible(t *testing.T) { + choices := []string{"Apple", "Apricot", "Banana", "Blueberry"} + m := newMS(choices) + + // Select all first + m = msCtrl(m, 'a') + + // Filter to "b" → Banana(2), Blueberry(3) + m = msRune(m, 'b') + + // Toggle off visible only + m = msCtrl(m, 'a') + + // Apple and Apricot should still be selected + if !m.selected[0] || !m.selected[1] { + t.Error("Apple and Apricot should still be selected") + } + // Banana and Blueberry should be deselected + if m.selected[2] || m.selected[3] { + t.Error("Banana and Blueberry should be deselected") + } +} + +func TestMultiSelectModel_CtrlARespectsMax(t *testing.T) { + choices := []string{"Apple", "Banana", "Cherry", "Date"} + m := newMS(choices, func(c *tui.MultiSelectConfig) { c.Max = 2 }) + + m = msCtrl(m, 'a') + + count := 0 + for range m.selected { + count++ + } + if count > 2 { + t.Errorf("expected at most 2 selected, got %d", count) + } + if m.err == "" { + t.Error("expected error message about max selections") + } +} + +// ============================================================ +// Navigation & selection tests +// ============================================================ + +func TestMultiSelectModel_ArrowNavigation(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}) + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.cursor != 1 { + t.Errorf("cursor after down = %d, want 1", m.cursor) + } + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyUp}) + if m.cursor != 0 { + t.Errorf("cursor after up = %d, want 0", m.cursor) + } +} + +func TestMultiSelectModel_ArrowWrapsWithLoop(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}) + + // Up from top wraps to bottom + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyUp}) + if m.cursor != 2 { + t.Errorf("cursor after wrap up = %d, want 2", m.cursor) + } + + // Down from bottom wraps to top + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.cursor != 0 { + t.Errorf("cursor after wrap down = %d, want 0", m.cursor) + } +} + +func TestMultiSelectModel_VimKeysWithoutFilter(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}) + + m = msRune(m, 'j') + if m.cursor != 1 { + t.Errorf("j should move down: cursor = %d, want 1", m.cursor) + } + if m.filter != "" { + t.Errorf("j should not set filter, got %q", m.filter) + } + + m = msRune(m, 'k') + if m.cursor != 0 { + t.Errorf("k should move up: cursor = %d, want 0", m.cursor) + } +} + +func TestMultiSelectModel_VimKeysTypeWhenFiltering(t *testing.T) { + m := newMS([]string{"Ajax", "Jolt", "Koji"}) + + // Start typing — 'a' begins filter, then 'j' goes into filter + m = msRune(m, 'a') + m = msRune(m, 'j') + + if m.filter != "aj" { + t.Errorf("filter = %q, want %q", m.filter, "aj") + } +} + +func TestMultiSelectModel_ArrowKeysOnEmptyMatches(t *testing.T) { + m := newMS([]string{"Apple", "Banana"}) + + for _, r := range "zzz" { + m = msRune(m, r) + } + if len(m.matched) != 0 { + t.Fatalf("expected 0 matches, got %d", len(m.matched)) + } + + // Should not panic + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyUp}) + + if m.cursor != 0 { + t.Errorf("cursor should remain 0, got %d", m.cursor) + } +} + +func TestMultiSelectModel_SpaceTogglesSelection(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}) + + // Select first item + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + if !m.selected[0] { + t.Error("Apple should be selected") + } + + // Deselect it + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + if m.selected[0] { + t.Error("Apple should be deselected") + } +} + +func TestMultiSelectModel_SpaceRespectsMax(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}, func(c *tui.MultiSelectConfig) { c.Max = 1 }) + + // Select first + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + // Move down, try to select second + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + + if m.selected[1] { + t.Error("Banana should NOT be selected — max is 1") + } + if m.err == "" { + t.Error("expected error message about max selections") + } +} + +func TestMultiSelectModel_SpaceOnEmptyMatchesIsNoop(t *testing.T) { + m := newMS([]string{"Apple", "Banana"}) + + for _, r := range "zzz" { + m = msRune(m, r) + } + + // Space on no matches should not panic + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + + if len(m.selected) != 0 { + t.Errorf("no items should be selected, got %d", len(m.selected)) + } +} + +func TestMultiSelectModel_EnterConfirms(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}) + + // Select Apple and Cherry + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if !m.done { + t.Error("expected done=true") + } + if !m.selected[0] || !m.selected[2] { + t.Error("Apple(0) and Cherry(2) should be selected") + } + if m.selected[1] { + t.Error("Banana(1) should not be selected") + } +} + +func TestMultiSelectModel_EnterRejectsIfBelowMin(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}, func(c *tui.MultiSelectConfig) { c.Min = 2 }) + + // Select only one + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + + result, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = result.(multiSelectModel) + + if m.done { + t.Error("should not be done — min not met") + } + if cmd != nil { + t.Error("should not quit when min not met") + } + if m.err == "" { + t.Error("expected error about minimum selections") + } +} + +func TestMultiSelectModel_MinErrorOverride(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}, + func(c *tui.MultiSelectConfig) { c.Min = 2 }, + func(c *tui.MultiSelectConfig) { + c.MinError = func(min int) string { return fmt.Sprintf("pick %d, friend", min) } + }, + ) + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) // one selection + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if m.err != "pick 2, friend" { + t.Errorf("expected overridden min error, got %q", m.err) + } +} + +func TestMultiSelectModel_MaxErrorOverride(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}, + func(c *tui.MultiSelectConfig) { c.Max = 1 }, + func(c *tui.MultiSelectConfig) { + c.MaxError = func(max int) string { return fmt.Sprintf("only %d allowed!", max) } + }, + ) + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) // first + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) // second — rejected + + if m.err != "only 1 allowed!" { + t.Errorf("expected overridden max error, got %q", m.err) + } +} + +func TestMultiSelectModel_MaxErrorOverride_ViaToggleAll(t *testing.T) { + // select-all (ctrl+a) is a separate code path from space-toggle; it must + // honor the override too. + m := newMS([]string{"Apple", "Banana", "Cherry"}, + func(c *tui.MultiSelectConfig) { c.Max = 2 }, + func(c *tui.MultiSelectConfig) { + c.MaxError = func(max int) string { return fmt.Sprintf("cap is %d", max) } + }, + ) + + m = msCtrl(m, 'a') // tries to select all 3, hits max at 2 + + if m.err != "cap is 2" { + t.Errorf("toggleAll path: expected overridden max error, got %q", m.err) + } +} + +func TestMultiSelectModel_MinErrorDefaultWhenUnset(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}, func(c *tui.MultiSelectConfig) { c.Min = 2 }) + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if !strings.Contains(m.err, "at least 2 selections required") { + t.Errorf("expected default min error, got %q", m.err) + } +} + +func TestMultiSelectModel_MaxErrorDefaultWhenUnset(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}, func(c *tui.MultiSelectConfig) { c.Max = 1 }) + + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) // rejected — over max + + if !strings.Contains(m.err, "maximum 1 selections allowed") { + t.Errorf("expected default max error, got %q", m.err) + } +} + +func TestMultiSelectModel_CtrlCInterrupts(t *testing.T) { + m := newMS([]string{"Apple", "Banana"}) + + m = msCtrl(m, 'c') + + if !m.interrupted { + t.Error("ctrl+c should set interrupted") + } +} + +func TestMultiSelectModel_DefaultSelections(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}, func(c *tui.MultiSelectConfig) { + c.Defaults = []int{0, 2} + }) + + if !m.selected[0] || !m.selected[2] { + t.Error("defaults 0 and 2 should be selected") + } + if m.selected[1] { + t.Error("index 1 should not be selected") + } +} + +func TestMultiSelectModel_NavigationInFilteredList(t *testing.T) { + m := newMS([]string{"Apple", "Apricot", "Banana", "Avocado"}) + + // Filter to "ap" → Apple(0), Apricot(1) + m = msRune(m, 'a') + m = msRune(m, 'p') + + if len(m.matched) != 2 { + t.Fatalf("expected 2 matches, got %d", len(m.matched)) + } + + // Move down within filtered list + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.cursor != 1 { + t.Errorf("cursor = %d, want 1", m.cursor) + } + + // Space selects the correct original item (Apricot = index 1) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + if !m.selected[1] { + t.Error("Apricot (original index 1) should be selected") + } + if m.selected[0] { + t.Error("Apple should not be selected") + } +} + +// ============================================================ +// View rendering tests +// ============================================================ + +func TestMultiSelectModel_ViewShowsFilter(t *testing.T) { + m := newMS([]string{"Apple", "Apricot", "Banana"}) + + m = msRune(m, 'a') + m = msRune(m, 'p') + + view := m.View().Content + + if !strings.Contains(view, "ap") { + t.Errorf("view should show filter text 'ap', got:\n%s", view) + } + if !strings.Contains(view, "Apple") { + t.Error("view should show Apple") + } + if !strings.Contains(view, "Apricot") { + t.Error("view should show Apricot") + } + if strings.Contains(view, "Banana") { + t.Error("view should NOT show Banana") + } +} + +func TestMultiSelectModel_ViewNoMatchMessage(t *testing.T) { + m := newMS([]string{"Apple", "Banana"}) + + m = msRune(m, 'z') + + view := m.View().Content + if !strings.Contains(view, "no matches") { + t.Errorf("view should show 'no matches', got:\n%s", view) + } +} + +func TestMultiSelectModel_ViewShowsCheckmarks(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}) + + // Select Apple + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + + view := m.View().Content + if !strings.Contains(view, "[x]") { + t.Error("view should show [x] for selected item") + } + if !strings.Contains(view, "[ ]") { + t.Error("view should show [ ] for unselected items") + } +} + +func TestMultiSelectModel_ViewDoneShowsSelected(t *testing.T) { + m := newMS([]string{"Apple", "Banana", "Cherry"}) + + // Select Apple and Cherry + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + view := m.View().Content + if !strings.Contains(view, "Apple") || !strings.Contains(view, "Cherry") { + t.Errorf("done view should show selected items, got:\n%s", view) + } + // Should not show checkboxes in done view + if strings.Contains(view, "[x]") || strings.Contains(view, "[ ]") { + t.Error("done view should not show checkboxes") + } +} + +func TestMultiSelectModel_View_HintBar(t *testing.T) { + const defaultBar = "↑/↓ navigate · space toggle · ctrl+a select all · type to filter · enter confirm · esc back · ctrl+c exit" + tests := []struct { + name string + mutate func(*tui.MultiSelectConfig) + wantContain []string + wantAbsent []string + }{ + { + name: "hints off by default", + mutate: func(c *tui.MultiSelectConfig) {}, + wantAbsent: []string{"navigate", "ctrl+c exit"}, + }, + { + name: "ShowHints renders default bar", + mutate: func(c *tui.MultiSelectConfig) { c.ShowHints = true }, + wantContain: []string{defaultBar}, + }, + { + name: "WithMultiSelectHints overrides defaults", + mutate: func(c *tui.MultiSelectConfig) { + c.ShowHints = true + c.Hints = []string{"↑/↓ move", "␣ check", "↵ done"} + }, + wantContain: []string{"↑/↓ move · ␣ check · ↵ done"}, + wantAbsent: []string{"navigate", "ctrl+a", "ctrl+c exit"}, + }, + { + name: "override ignored when ShowHints off", + mutate: func(c *tui.MultiSelectConfig) { + c.Hints = []string{"x"} + }, + wantAbsent: []string{"navigate", "x"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newMS([]string{"a", "b"}, tt.mutate) + view := m.View().Content + for _, s := range tt.wantContain { + if !strings.Contains(view, s) { + t.Errorf("expected view to contain %q, got:\n%s", s, view) + } + } + for _, s := range tt.wantAbsent { + if strings.Contains(view, s) { + t.Errorf("expected view to NOT contain %q, got:\n%s", s, view) + } + } + }) + } +} + +func TestMultiSelectModel_HintBar_AbsentInDoneView(t *testing.T) { + m := newMS([]string{"alpha", "beta"}, func(c *tui.MultiSelectConfig) { c.ShowHints = true }) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeySpace}) + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.done { + t.Fatal("expected done=true") + } + view := m.View().Content + if strings.Contains(view, "navigate") { + t.Errorf("done view must not render hint bar, got:\n%s", view) + } +} + +func TestMultiSelectModel_HintBar_AtBottomBelowChoices(t *testing.T) { + m := newMS([]string{"alpha", "beta"}, func(c *tui.MultiSelectConfig) { c.ShowHints = true }) + view := m.View().Content + choicesAt := strings.Index(view, "alpha") + hintsAt := strings.Index(view, "↑/↓ navigate") + if choicesAt < 0 || hintsAt < 0 { + t.Fatalf("expected both choices and hint bar in view, got:\n%s", view) + } + if hintsAt <= choicesAt { + t.Errorf("hint bar should be after choices: choices@%d hints@%d", choicesAt, hintsAt) + } +} + +func TestMultiSelectModel_ViewShowsError(t *testing.T) { + m := newMS([]string{"Apple", "Banana"}, func(c *tui.MultiSelectConfig) { c.Min = 2 }) + + // Try to confirm with 0 selected + m = msKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + view := m.View().Content + if !strings.Contains(view, "at least 2") { + t.Errorf("view should show min error, got:\n%s", view) + } +} diff --git a/pkg/tui/bubbletea/pager.go b/pkg/tui/bubbletea/pager.go new file mode 100644 index 0000000..552e962 --- /dev/null +++ b/pkg/tui/bubbletea/pager.go @@ -0,0 +1,147 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/term" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// terminalHeight returns the terminal height for the given writer, +// defaulting to 24 if detection fails (e.g., piped output). +func terminalHeight(w io.Writer) int { + if f, ok := w.(*os.File); ok { + _, h, err := term.GetSize(f.Fd()) + if err == nil && h > 0 { + return h + } + } + return 24 +} + +type pagerModel struct { + viewport viewport.Model + title string + ready bool + quitting bool +} + +func newPagerModel(content string, cfg tui.PagerConfig) pagerModel { + // Add line numbers if requested. + if cfg.LineNumbers { + lines := strings.Split(content, "\n") + width := len(fmt.Sprintf("%d", len(lines))) + for i, line := range lines { + lines[i] = fmt.Sprintf("%*d %s", width, i+1, line) + } + content = strings.Join(lines, "\n") + } + + m := pagerModel{ + title: cfg.Title, + } + // Viewport will be sized on first WindowSizeMsg. + m.viewport = viewport.New(viewport.WithWidth(80), viewport.WithHeight(24)) + m.viewport.SetContent(content) + return m +} + +func (m pagerModel) Init() tea.Cmd { + return nil +} + +func (m pagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + headerHeight := 0 + if m.title != "" { + headerHeight = 1 + } + footerHeight := 1 + m.viewport.SetWidth(msg.Width) + m.viewport.SetHeight(msg.Height - headerHeight - footerHeight) + m.ready = true + case tea.KeyPressMsg: + switch msg.String() { + case "q", keyEsc, keyCtrlC: + m.quitting = true + return m, tea.Quit + } + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m pagerModel) View() tea.View { + if !m.ready { + return tea.NewView("Loading...") + } + + var b strings.Builder + + if m.title != "" { + titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14")) + b.WriteString(titleStyle.Render(m.title)) + b.WriteString("\n") + } + + b.WriteString(m.viewport.View()) + b.WriteString("\n") + + // Footer with scroll position. + pct := m.viewport.ScrollPercent() * 100 + footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + footer := fmt.Sprintf(" ↑/↓ scroll • q quit • %.0f%%", pct) + b.WriteString(footerStyle.Render(footer)) + + v := tea.NewView(b.String()) + v.AltScreen = true + return v +} + +// Pager implements tui.Status. +func (p *Prompter) Pager(ctx context.Context, content string, opts ...tui.PagerOption) error { + cfg := tui.ResolvePagerConfig(opts) + + // Auto-detect: if content fits in terminal, just print it. + lines := strings.Count(content, "\n") + 1 + termHeight := terminalHeight(p.out) + if lines <= termHeight-2 { // leave room for prompt + _, err := fmt.Fprint(p.out, content) + return err + } + + model := newPagerModel(content, cfg) + + program := tea.NewProgram(model, + tea.WithInput(p.in), + tea.WithOutput(p.out), + tea.WithContext(ctx), + ) + + _, err := program.Run() + return err +} diff --git a/pkg/tui/bubbletea/password.go b/pkg/tui/bubbletea/password.go new file mode 100644 index 0000000..bc9002d --- /dev/null +++ b/pkg/tui/bubbletea/password.go @@ -0,0 +1,139 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +type passwordModel struct { + prompt string + textInput textinput.Model + submitted bool + aborted bool + interrupted bool // true for Ctrl+C (hard cancel), false for Esc (soft cancel) + bindings []KeyBinding[passwordModel] +} + +// DefaultPasswordBindings returns a fresh copy of the canonical +// binding set. Stable IDs: submit, esc, exit. +func DefaultPasswordBindings() []KeyBinding[passwordModel] { + return []KeyBinding[passwordModel]{ + { + ID: "submit", + Match: MatchKey(tea.KeyEnter), + Label: func(*passwordModel) string { return hintEnterEntry }, + Handle: func(m *passwordModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.submitted = true + return tea.Quit, true + }, + }, + { + ID: "esc", + Match: MatchKey(tea.KeyEscape), + Label: func(*passwordModel) string { return hintEscBack }, + Handle: func(m *passwordModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.aborted = true + return func() tea.Msg { return GoBackMsg{} }, true + }, + }, + { + ID: "exit", + Match: MatchRune('c', tea.ModCtrl), + Label: func(*passwordModel) string { return hintCtrlCExit }, + Handle: func(m *passwordModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.interrupted = true + return tea.Quit, true + }, + }, + } +} + +func newPasswordModel(prompt string) passwordModel { + ti := textinput.New() + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + ti.Focus() + + return passwordModel{ + prompt: prompt, + textInput: ti, + bindings: DefaultPasswordBindings(), + } +} + +func (m passwordModel) Init() tea.Cmd { return textinput.Blink } + +func (m passwordModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(GoBackMsg); ok { + return m, tea.Quit // standalone mode quit; wizard composite intercepts before this + } + if key, ok := msg.(tea.KeyPressMsg); ok { + if cmd, stopped := Dispatch(&m, m.bindings, key); stopped { + return m, cmd + } + } + + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd +} + +// Hints derives key hints from the resolved bindings. +func (m passwordModel) Hints() []string { + return HintsFor(&m, m.bindings) +} + +// Result returns the password value after the user submits. +func (m passwordModel) Result() (any, bool) { + return m.textInput.Value(), m.submitted +} + +// NewPasswordPrompt creates a password prompt model for use in the wizard composite. +func NewPasswordPrompt(prompt string) PromptModel { + return newPasswordModel(prompt) +} + +func (m passwordModel) View() tea.View { + if m.submitted { + return tea.NewView(fmt.Sprintf("%s %s %s\n", promptStyle.Render("?"), titleStyle.Render(m.prompt), hintStyle.Render("[hidden]"))) + } + return tea.NewView(fmt.Sprintf("%s %s\n%s", promptStyle.Render("?"), titleStyle.Render(m.prompt), m.textInput.View())) +} + +// Password implements tui.Prompter. +func (p *Prompter) Password(ctx context.Context, prompt string) (string, error) { + model := newPasswordModel(prompt) + + r := p.runProgram(ctx, model) + if r.interrupted { + return "", tui.ErrInterrupted + } + if r.err != nil { + return "", fmt.Errorf("password prompt: %w", r.err) + } + + m := r.model.(passwordModel) + if m.aborted { + return "", context.Canceled + } + return m.textInput.Value(), nil +} diff --git a/pkg/tui/bubbletea/password_bindings_test.go b/pkg/tui/bubbletea/password_bindings_test.go new file mode 100644 index 0000000..b39e64e --- /dev/null +++ b/pkg/tui/bubbletea/password_bindings_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestPasswordBindings_DefaultHintsOrder(t *testing.T) { + m := newPasswordModel("Token?") + got := strings.Join(m.Hints(), " · ") + want := "enter submit · esc back · ctrl+c exit" + if got != want { + t.Errorf("default hint order drifted\n got: %s\nwant: %s", got, want) + } +} + +func TestPasswordBindings_EnterSubmits(t *testing.T) { + m := newPasswordModel("Token?") + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !updated.(passwordModel).submitted { + t.Error("enter should submit") + } +} + +func TestPasswordBindings_EscAborts(t *testing.T) { + m := newPasswordModel("Token?") + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if !updated.(passwordModel).aborted { + t.Error("esc should mark aborted") + } + if cmd == nil { + t.Fatal("esc should produce a command") + } + if _, ok := cmd().(GoBackMsg); !ok { + t.Error("esc should produce GoBackMsg") + } +} + +func TestPasswordBindings_CtrlCInterrupts(t *testing.T) { + m := newPasswordModel("Token?") + updated, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if !updated.(passwordModel).interrupted { + t.Error("ctrl+c should set interrupted") + } +} + +// Printable text falls through to the bubbles textinput, populating +// the underlying value (which is echoed as bullets). +func TestPasswordBindings_TypingFallsThroughToTextinput(t *testing.T) { + m := newPasswordModel("Token?") + for _, r := range "secret" { + updated, _ := m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) + m = updated.(passwordModel) + } + if m.textInput.Value() != "secret" { + t.Errorf("expected underlying value 'secret', got %q", m.textInput.Value()) + } +} diff --git a/pkg/tui/bubbletea/password_prompt_test.go b/pkg/tui/bubbletea/password_prompt_test.go new file mode 100644 index 0000000..b526bc6 --- /dev/null +++ b/pkg/tui/bubbletea/password_prompt_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestPasswordPrompt_Hints(t *testing.T) { + m := NewPasswordPrompt("Secret:") + hints := m.Hints() + if len(hints) == 0 { + t.Fatal("expected hints") + } +} + +func TestPasswordPrompt_TypeAndEnter(t *testing.T) { + m := NewPasswordPrompt("Secret:") + + // Type "pass" + for _, ch := range "pass" { + updated, _ := m.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + m = updated.(PromptModel) + } + + // Enter + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(PromptModel) + + val, done := m.Result() + if !done { + t.Fatal("expected done after Enter") + } + if val != "pass" { + t.Errorf("expected 'pass', got %v", val) + } +} + +func TestPasswordPrompt_EscGoBack(t *testing.T) { + m := NewPasswordPrompt("Secret:") + + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil { + t.Fatal("expected command from Esc") + } + msg := cmd() + if _, ok := msg.(GoBackMsg); !ok { + t.Fatalf("expected GoBackMsg, got %T", msg) + } +} + +func TestPasswordPrompt_Result_NotDoneInitially(t *testing.T) { + m := NewPasswordPrompt("Secret:") + _, done := m.Result() + if done { + t.Fatal("should not be done initially") + } +} diff --git a/pkg/tui/bubbletea/progress.go b/pkg/tui/bubbletea/progress.go new file mode 100644 index 0000000..f13e29d --- /dev/null +++ b/pkg/tui/bubbletea/progress.go @@ -0,0 +1,166 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + "sync" + + "charm.land/bubbles/v2/progress" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// --- Messages --- + +type progressSetMsg struct{ percent float64 } +type progressIncrMsg struct{ delta float64 } +type progressStopMsg struct{ finalMessage string } + +// --- Model --- + +type progressModel struct { + progress progress.Model + message string + percent float64 + done bool + finalMessage string + autoStop bool +} + +func newProgressModel(message string, cfg tui.ProgressConfig) progressModel { + var opts []progress.Option + opts = append(opts, progress.WithWidth(cfg.Width)) + if !cfg.ShowPercent { + opts = append(opts, progress.WithoutPercentage()) + } + if cfg.SolidFill != "" { + opts = append(opts, progress.WithColors(lipgloss.Color(cfg.SolidFill))) + } else { + opts = append(opts, progress.WithColors(lipgloss.Color(cfg.ColorA), lipgloss.Color(cfg.ColorB))) + } + + return progressModel{ + progress: progress.New(opts...), + message: message, + autoStop: cfg.AutoStop, + } +} + +func (m progressModel) Init() tea.Cmd { return nil } + +func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case progressSetMsg: + m.percent = msg.percent + if m.percent > 1.0 { + m.percent = 1.0 + } + cmd := m.progress.SetPercent(m.percent) + if m.autoStop && m.percent >= 1.0 { + m.done = true + m.finalMessage = m.message + return m, tea.Sequence(cmd, tea.Quit) + } + return m, cmd + case progressIncrMsg: + m.percent += msg.delta + if m.percent > 1.0 { + m.percent = 1.0 + } + cmd := m.progress.SetPercent(m.percent) + if m.autoStop && m.percent >= 1.0 { + m.done = true + m.finalMessage = m.message + return m, tea.Sequence(cmd, tea.Quit) + } + return m, cmd + case progressStopMsg: + m.done = true + if msg.finalMessage != "" { + m.finalMessage = msg.finalMessage + } else { + m.finalMessage = m.message + } + return m, tea.Quit + case progress.FrameMsg: + var cmd tea.Cmd + m.progress, cmd = m.progress.Update(msg) + return m, cmd + case tea.KeyPressMsg: + if msg.String() == keyCtrlC { + m.done = true + m.finalMessage = m.message + return m, tea.Quit + } + } + return m, nil +} + +func (m progressModel) View() tea.View { + if m.done { + return tea.NewView(fmt.Sprintf("✓ %s\n", m.finalMessage)) + } + return tea.NewView(fmt.Sprintf("%s %s", m.message, m.progress.View())) +} + +// --- Handle --- + +type progressHandle struct { + program *tea.Program + once sync.Once + done chan struct{} +} + +func (h *progressHandle) SetPercent(p float64) { + h.program.Send(progressSetMsg{percent: p}) +} + +func (h *progressHandle) Increment(delta float64) { + h.program.Send(progressIncrMsg{delta: delta}) +} + +func (h *progressHandle) Stop(finalMessage string) { + h.once.Do(func() { + h.program.Send(progressStopMsg{finalMessage: finalMessage}) + <-h.done + }) +} + +// Progress implements tui.Status. +func (p *Prompter) Progress(ctx context.Context, message string, opts ...tui.ProgressOption) (tui.ProgressHandle, error) { + cfg := tui.ResolveProgressConfig(opts) + model := newProgressModel(message, cfg) + + program := tea.NewProgram(model, + tea.WithInput(p.in), + tea.WithOutput(p.out), + tea.WithContext(ctx), + ) + + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = program.Run() + }() + + return &progressHandle{ + program: program, + done: done, + }, nil +} diff --git a/pkg/tui/bubbletea/prompt_model.go b/pkg/tui/bubbletea/prompt_model.go new file mode 100644 index 0000000..f3bb42c --- /dev/null +++ b/pkg/tui/bubbletea/prompt_model.go @@ -0,0 +1,36 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import tea "charm.land/bubbletea/v2" + +// GoBackMsg is returned as a tea.Cmd by prompt models when the user +// presses Esc and there is no internal state to clear (e.g. no active filter). +// The composite model receives this and navigates back. +type GoBackMsg struct{} + +// PromptModel is a tea.Model that reports domain-level hints and completion state. +// Used by the wizard composite model to embed prompts without running separate programs. +type PromptModel interface { + tea.Model + + // Hints returns prompt-specific key hints (e.g. "↑/↓ navigate", "enter select"). + // These are merged with wizard-level hints in the hint bar. + Hints() []string + + // Result returns the prompt outcome after the user submits. + // done=false means the prompt is still active. + Result() (value any, done bool) +} diff --git a/pkg/tui/bubbletea/prompter.go b/pkg/tui/bubbletea/prompter.go new file mode 100644 index 0000000..b147cda --- /dev/null +++ b/pkg/tui/bubbletea/prompter.go @@ -0,0 +1,126 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "errors" + "io" + "os" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// runResult holds the outcome of a Bubble Tea program run, including +// whether the exit was caused by SIGINT (Ctrl+C). +type runResult struct { + model tea.Model + err error + interrupted bool // true if SIGINT was received during Run +} + +// runProgram runs a tea.Program and translates Bubble Tea's ErrInterrupted +// (returned when Ctrl+C / SIGINT is received) into our tui.ErrInterrupted. +// +// In Bubble Tea v2, Ctrl+C generates SIGINT which the framework catches and +// returns as tea.ErrInterrupted from program.Run(). The model never sees +// the key event. This method detects that and sets the interrupted flag. +func (p *Prompter) runProgram(ctx context.Context, model tea.Model) runResult { + program := tea.NewProgram(model, + tea.WithInput(p.in), + tea.WithOutput(p.out), + tea.WithContext(ctx), + ) + + result, err := program.Run() + + // Bubble Tea returns tea.ErrInterrupted when SIGINT (Ctrl+C) is received. + interrupted := errors.Is(err, tea.ErrInterrupted) + + // Also check the model's interrupted flag (in case raw mode + // delivered Ctrl+C as a key event rather than a signal). + if !interrupted { + switch m := result.(type) { + case selectModel: + interrupted = m.interrupted + case multiSelectModel: + interrupted = m.interrupted + case textInputModel: + interrupted = m.interrupted + case confirmModel: + interrupted = m.interrupted + case passwordModel: + interrupted = m.interrupted + } + } + + // Clear the framework error if we're handling it as interrupted. + if interrupted { + err = nil + } + + return runResult{model: result, err: err, interrupted: interrupted} +} + +// Prompter implements tui.Prompter using Bubbletea. +type Prompter struct { + in io.Reader + out io.Writer + errOut io.Writer +} + +// New creates a Bubbletea-backed Prompter. +func New(ioOpts ...func(*Prompter)) *Prompter { + p := &Prompter{ + in: os.Stdin, + out: os.Stdout, + errOut: os.Stderr, + } + for _, o := range ioOpts { + o(p) + } + return p +} + +// WithIO configures the prompter with custom IO streams. +func WithIO(io tui.IO) func(*Prompter) { + return func(p *Prompter) { + if io.In != nil { + p.in = io.In + } + if io.Out != nil { + p.out = io.Out + } + if io.ErrOut != nil { + p.errOut = io.ErrOut + } + } +} + +// Compile-time interface checks. +var _ tui.Prompter = (*Prompter)(nil) +var _ tui.Status = (*Prompter)(nil) +var _ tui.LiveLister = (*Prompter)(nil) + +func init() { + tui.RegisterBuilder(func(_ ...func(*tui.IO)) tui.Prompter { + return New() + }) + tui.RegisterStatusBuilder(func(_ ...func(*tui.IO)) tui.Status { + return New() + }) +} diff --git a/pkg/tui/bubbletea/select.go b/pkg/tui/bubbletea/select.go new file mode 100644 index 0000000..eb54dd5 --- /dev/null +++ b/pkg/tui/bubbletea/select.go @@ -0,0 +1,334 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +type selectModel struct { + prompt string + choices []string // original full list — never mutated + filter string // current filter text + matched []int // indices into choices that match filter + cursor int // position within matched (not choices) + pageSize int + loop bool + showHints bool + customHints []string // nil = derive from bindings + bindings []KeyBinding[selectModel] // resolved (defaults + overrides + extras) + chosen bool + aborted bool + interrupted bool // true for Ctrl+C (hard cancel), false for Esc (soft cancel) +} + +// DefaultSelectBindings returns a fresh copy of the canonical binding +// set. Stable IDs for WithSelectRelabel / WithSelectHide: navigate, +// vim-up, vim-down, filter-type, select, esc, filter-backspace, exit. +func DefaultSelectBindings() []KeyBinding[selectModel] { + return []KeyBinding[selectModel]{ + { + ID: "navigate", + Match: MatchKey(tea.KeyUp, tea.KeyDown), + Label: func(*selectModel) string { return "↑/↓ navigate" }, + Handle: func(m *selectModel, msg tea.KeyPressMsg) (tea.Cmd, bool) { + if msg.Code == tea.KeyUp { + m.moveUp() + } else { + m.moveDown() + } + return nil, true + }, + }, + { + ID: "vim-up", + Match: MatchRune('k'), + Label: func(*selectModel) string { return "" }, + // j/k navigate when filter is empty; otherwise pass through so + // filter-type appends the key to the filter. + Handle: func(m *selectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if m.filter != "" { + return nil, false + } + m.moveUp() + return nil, true + }, + }, + { + ID: "vim-down", + Match: MatchRune('j'), + Label: func(*selectModel) string { return "" }, + Handle: func(m *selectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if m.filter != "" { + return nil, false + } + m.moveDown() + return nil, true + }, + }, + { + ID: "filter-type", + Match: MatchText(), + Label: func(*selectModel) string { return "type to filter" }, + Handle: func(m *selectModel, msg tea.KeyPressMsg) (tea.Cmd, bool) { + m.filter += msg.Text + m.refilter() + return nil, true + }, + }, + { + ID: "select", + Match: MatchKey(tea.KeyEnter), + Label: func(*selectModel) string { return "enter select" }, + Handle: func(m *selectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if len(m.matched) == 0 { + return nil, true + } + m.chosen = true + return tea.Quit, true + }, + }, + { + ID: "esc", + Match: MatchKey(tea.KeyEscape), + Label: func(m *selectModel) string { + if m.filter != "" { + return "esc clear filter" + } + return hintEscBack + }, + Handle: func(m *selectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if m.filter != "" { + m.filter = "" + m.refilter() + return nil, true + } + m.aborted = true + return func() tea.Msg { return GoBackMsg{} }, true + }, + }, + { + ID: "filter-backspace", + Match: MatchKey(tea.KeyBackspace), + Label: func(*selectModel) string { return "" }, + Handle: func(m *selectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if len(m.filter) > 0 { + _, size := utf8.DecodeLastRuneInString(m.filter) + m.filter = m.filter[:len(m.filter)-size] + m.refilter() + } + return nil, true + }, + }, + { + ID: "exit", + Match: MatchRune('c', tea.ModCtrl), + Label: func(*selectModel) string { return hintCtrlCExit }, + Handle: func(m *selectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.interrupted = true + return tea.Quit, true + }, + }, + } +} + +// WithSelectAddBindings prepends extras so they outrank the default +// catch-all matchers (e.g. a '?' help binding won't be swallowed by +// filter-type). KeyBinding[selectModel] references an unexported type, +// so only in-package callers can construct one — external code uses +// WithSelectRelabel / WithSelectHide for label-only changes. +func WithSelectAddBindings(extras ...KeyBinding[selectModel]) tui.SelectOption { + return func(c *tui.SelectConfig) { + existing, _ := c.ExtraBindings.([]KeyBinding[selectModel]) + c.ExtraBindings = append(existing, extras...) + } +} + +func newSelectModel(prompt string, choices []string, cfg tui.SelectConfig) selectModel { + ps := cfg.PageSize + if ps <= 0 || ps > len(choices) { + ps = len(choices) + } + cursor := cfg.Default + if cursor < 0 || cursor >= len(choices) { + cursor = 0 + } + matched := make([]int, len(choices)) + for i := range choices { + matched[i] = i + } + defaults := ApplyBindingOverrides(DefaultSelectBindings(), cfg.RelabelByID, cfg.HiddenByID) + var bindings []KeyBinding[selectModel] + if extras, ok := cfg.ExtraBindings.([]KeyBinding[selectModel]); ok && len(extras) > 0 { + bindings = make([]KeyBinding[selectModel], 0, len(extras)+len(defaults)) + bindings = append(bindings, extras...) + bindings = append(bindings, defaults...) + } else { + bindings = defaults + } + return selectModel{ + prompt: prompt, + choices: choices, + filter: "", + matched: matched, + cursor: cursor, + pageSize: ps, + loop: cfg.Loop, + showHints: cfg.ShowHints, + customHints: cfg.Hints, + bindings: bindings, + } +} + +func (m *selectModel) refilter() { + m.matched = refilter(m.filter, m.choices, m.matched) + m.cursor = 0 +} + +func (m *selectModel) moveUp() { + if len(m.matched) == 0 { + return + } + if m.cursor > 0 { + m.cursor-- + } else if m.loop { + m.cursor = len(m.matched) - 1 + } +} + +func (m *selectModel) moveDown() { + if len(m.matched) == 0 { + return + } + if m.cursor < len(m.matched)-1 { + m.cursor++ + } else if m.loop { + m.cursor = 0 + } +} + +func (m selectModel) Init() tea.Cmd { return nil } + +func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(GoBackMsg); ok { + // Standalone mode quit: wizard composite intercepts GoBackMsg + // before forwarding, so this branch fires only outside a wizard. + return m, tea.Quit + } + key, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + cmd, _ := Dispatch(&m, m.bindings, key) + return m, cmd +} + +func (m selectModel) View() tea.View { + if m.chosen { + selected := m.choices[m.matched[m.cursor]] + return tea.NewView(fmt.Sprintf("%s %s %s\n", promptStyle.Render("?"), titleStyle.Render(m.prompt), answerStyle.Render(selected))) + } + + var b strings.Builder + fmt.Fprintf(&b, "%s %s", promptStyle.Render("?"), titleStyle.Render(m.prompt)) + if m.filter != "" { + fmt.Fprintf(&b, " %s", answerStyle.Render(m.filter)) + } + b.WriteString("\n") + + if len(m.matched) == 0 { + fmt.Fprintf(&b, " %s\n", dimStyle.Render("no matches")) + m.renderHintBar(&b) + return tea.NewView(b.String()) + } + + start, end := m.visibleRange() + for i := start; i < end; i++ { + label := m.choices[m.matched[i]] + if i == m.cursor { + fmt.Fprintf(&b, " %s %s\n", cursorStyle.Render(">"), selectedStyle.Render(label)) + } else { + fmt.Fprintf(&b, " %s\n", dimStyle.Render(label)) + } + } + m.renderHintBar(&b) + return tea.NewView(b.String()) +} + +func (m selectModel) renderHintBar(b *strings.Builder) { + if !m.showHints { + return + } + fmt.Fprintf(b, "\n%s\n", dimStyle.Render(strings.Join(m.Hints(), " · "))) +} + +func (m selectModel) visibleRange() (int, int) { + return visibleWindow(len(m.matched), m.cursor, m.pageSize) +} + +// Hints returns the hint bar entries. customHints (from WithHints) +// wins; otherwise hints derive from the binding set via HintsFor. +func (m selectModel) Hints() []string { + if m.customHints != nil { + return m.customHints + } + return HintsFor(&m, m.bindings) +} + +// Result returns the selected index after the user presses Enter. +func (m selectModel) Result() (any, bool) { + if !m.chosen || len(m.matched) == 0 { + return nil, false + } + return m.matched[m.cursor], true +} + +// NewSelectPrompt creates a select prompt model for use in the wizard composite. +// The returned model handles domain keys only (arrows, Enter, type-to-filter, Esc). +// Ctrl+C is NOT handled — the composite model intercepts it. +func NewSelectPrompt(prompt string, choices []string, cfg tui.SelectConfig) PromptModel { + return newSelectModel(prompt, choices, cfg) +} + +// Select implements tui.Prompter. +func (p *Prompter) Select(ctx context.Context, prompt string, choices []string, opts ...tui.SelectOption) (int, error) { + if len(choices) == 0 { + return -1, fmt.Errorf("select prompt: no choices provided") + } + + cfg := tui.ResolveSelectConfig(opts) + model := newSelectModel(prompt, choices, cfg) + + r := p.runProgram(ctx, model) + if r.interrupted { + return -1, tui.ErrInterrupted + } + if r.err != nil { + return -1, fmt.Errorf("select prompt: %w", r.err) + } + + m := r.model.(selectModel) + if m.aborted { + return -1, context.Canceled + } + return m.matched[m.cursor], nil +} diff --git a/pkg/tui/bubbletea/select_bindings_test.go b/pkg/tui/bubbletea/select_bindings_test.go new file mode 100644 index 0000000..3e469c1 --- /dev/null +++ b/pkg/tui/bubbletea/select_bindings_test.go @@ -0,0 +1,210 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "slices" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// Dynamic label: Esc reads "esc back" with empty filter, "esc clear +// filter" when filter is active. This is the headline payoff of the +// binding refactor — labels are now closures over model state. +func TestSelectBindings_DynamicEscLabel(t *testing.T) { + cfg := tui.ResolveSelectConfig([]tui.SelectOption{tui.WithShowHints(true)}) + m := newSelectModel("Pick", []string{"alpha", "beta"}, cfg) + + // No filter yet → "esc back". + hints := m.Hints() + if !containsString(hints, "esc back") { + t.Errorf("expected 'esc back' before filtering, got %v", hints) + } + if containsString(hints, "esc clear filter") { + t.Errorf("did not expect 'esc clear filter' before filtering, got %v", hints) + } + + // Type a filter char. + m = sendRune(m, 'a') + hints = m.Hints() + if !containsString(hints, "esc clear filter") { + t.Errorf("expected 'esc clear filter' while filtering, got %v", hints) + } + if containsString(hints, "esc back") { + t.Errorf("did not expect 'esc back' while filtering, got %v", hints) + } + + // Clear filter via Esc → back to "esc back". + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyEscape}) + hints = m.Hints() + if !containsString(hints, "esc back") { + t.Errorf("expected 'esc back' after filter cleared, got %v", hints) + } +} + +// View renders the dynamic label too — not just Hints(). +func TestSelectBindings_DynamicEscLabel_InView(t *testing.T) { + cfg := tui.ResolveSelectConfig([]tui.SelectOption{tui.WithShowHints(true)}) + m := newSelectModel("Pick", []string{"alpha", "beta"}, cfg) + m = sendRune(m, 'a') + + view := m.View().Content + if !strings.Contains(view, "esc clear filter") { + t.Errorf("expected dynamic label in view, got:\n%s", view) + } + if strings.Contains(view, "esc back") { + t.Errorf("expected old label NOT in view while filtering, got:\n%s", view) + } +} + +// WithSelectRelabel renames a binding by ID without touching key handling. +func TestSelectBindings_WithSelectRelabel(t *testing.T) { + cfg := tui.ResolveSelectConfig([]tui.SelectOption{ + tui.WithShowHints(true), + tui.WithSelectRelabel("esc", "esc cancel"), + tui.WithSelectRelabel("select", "↵ open"), + }) + m := newSelectModel("Pick", []string{"alpha"}, cfg) + + hints := m.Hints() + if !containsString(hints, "esc cancel") { + t.Errorf("expected relabeled esc, got %v", hints) + } + if !containsString(hints, "↵ open") { + t.Errorf("expected relabeled select, got %v", hints) + } + if containsString(hints, "esc back") { + t.Errorf("relabel did not replace default, got %v", hints) + } + + // Key handling still works — Enter completes the prompt. + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.chosen { + t.Error("Enter should still complete after relabel") + } +} + +// WithSelectHide removes labels but keeps key handling. +func TestSelectBindings_WithSelectHide(t *testing.T) { + cfg := tui.ResolveSelectConfig([]tui.SelectOption{ + tui.WithShowHints(true), + tui.WithSelectHide("exit", "select"), + }) + m := newSelectModel("Pick", []string{"alpha"}, cfg) + + hints := m.Hints() + if containsString(hints, "ctrl+c exit") { + t.Errorf("exit should be hidden, got %v", hints) + } + if containsString(hints, "enter select") { + t.Errorf("select should be hidden, got %v", hints) + } + if !containsString(hints, "↑/↓ navigate") { + t.Errorf("other defaults must remain, got %v", hints) + } + + // Ctrl+C still triggers exit (sets interrupted). + m = sendKey(m, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if !m.interrupted { + t.Error("ctrl+c should still interrupt even when hidden") + } +} + +// WithSelectAddBindings appends a custom binding (e.g. '?' help). +// The new binding both renders its label and triggers its handler. +func TestSelectBindings_WithSelectAddBindings(t *testing.T) { + helped := false + help := KeyBinding[selectModel]{ + ID: "help", + Match: MatchRune('?'), + Label: func(*selectModel) string { return "? help" }, + Handle: func(_ *selectModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + helped = true + return nil, true + }, + } + cfg := tui.ResolveSelectConfig([]tui.SelectOption{ + tui.WithShowHints(true), + WithSelectAddBindings(help), + }) + m := newSelectModel("Pick", []string{"alpha"}, cfg) + + if !containsString(m.Hints(), "? help") { + t.Errorf("custom binding label missing, got %v", m.Hints()) + } + + m = sendRune(m, '?') + if !helped { + t.Error("custom binding handler did not fire") + } + // Filter must NOT have absorbed '?' (custom binding stopped dispatch). + if m.filter == "?" { + t.Error("custom binding should have stopped before filter-type") + } +} + +// Default binding order yields the historical hint string verbatim. +// Locks in compatibility for any consumer asserting on display order. +func TestSelectBindings_DefaultHintsOrder(t *testing.T) { + cfg := tui.ResolveSelectConfig(nil) + m := newSelectModel("Pick", []string{"a"}, cfg) + got := strings.Join(m.Hints(), " · ") + want := "↑/↓ navigate · type to filter · enter select · esc back · ctrl+c exit" + if got != want { + t.Errorf("default hint sequence drifted\n got: %s\nwant: %s", got, want) + } +} + +// Pressing Esc in standalone mode must actually quit the bubbletea +// program (via tea.Quit) so Prompter.Select returns context.Canceled. +// In wizard mode the composite intercepts GoBackMsg before it reaches +// the prompt, so this test specifically targets the standalone path. +func TestSelectBindings_EscQuitsInStandaloneMode(t *testing.T) { + cfg := tui.ResolveSelectConfig(nil) + m := newSelectModel("Pick", []string{"a", "b"}, cfg) + + // 1. Esc with no filter → sets aborted, emits GoBackMsg cmd. + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + got := updated.(selectModel) + if !got.aborted { + t.Fatal("Esc should set aborted=true") + } + if cmd == nil { + t.Fatal("Esc should produce a cmd") + } + msg := cmd() + if _, ok := msg.(GoBackMsg); !ok { + t.Fatalf("Esc cmd should emit GoBackMsg, got %T", msg) + } + + // 2. Feeding GoBackMsg back into Update should yield tea.Quit so + // the program terminates instead of looping forever. + _, quitCmd := got.Update(GoBackMsg{}) + if quitCmd == nil { + t.Fatal("GoBackMsg should produce a cmd") + } + if _, isQuit := quitCmd().(tea.QuitMsg); !isQuit { + t.Errorf("GoBackMsg should produce tea.QuitMsg, got %T", quitCmd()) + } +} + +// containsString is a small helper to keep assertions readable. +func containsString(xs []string, s string) bool { + return slices.Contains(xs, s) +} diff --git a/pkg/tui/bubbletea/select_prompt_test.go b/pkg/tui/bubbletea/select_prompt_test.go new file mode 100644 index 0000000..4b2baac --- /dev/null +++ b/pkg/tui/bubbletea/select_prompt_test.go @@ -0,0 +1,191 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func TestSelectPrompt_Hints(t *testing.T) { + cfg := tui.ResolveSelectConfig(nil) + m := NewSelectPrompt("Pick one", []string{"a", "b"}, cfg) + hints := m.Hints() + if len(hints) == 0 { + t.Fatal("expected hints") + } +} + +func TestSelectPrompt_ArrowAndEnter(t *testing.T) { + cfg := tui.ResolveSelectConfig(nil) + m := NewSelectPrompt("Pick", []string{"alpha", "beta"}, cfg) + + // Move down + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + m = updated.(PromptModel) + + // Press Enter + updated, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(PromptModel) + + val, done := m.Result() + if !done { + t.Fatal("expected done after Enter") + } + if val != 1 { // index 1 = "beta" + t.Errorf("expected index 1, got %v", val) + } +} + +func TestSelectPrompt_EscWithFilter_ClearsFilter(t *testing.T) { + cfg := tui.ResolveSelectConfig(nil) + m := NewSelectPrompt("Pick", []string{"alpha", "beta"}, cfg) + + // Type to filter + updated, _ := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + m = updated.(PromptModel) + + // Esc should clear filter, not go back + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + _ = updated.(PromptModel) + + // Should NOT produce GoBackMsg + if cmd != nil { + msg := cmd() + if _, ok := msg.(GoBackMsg); ok { + t.Fatal("Esc with active filter should clear filter, not go back") + } + } +} + +func TestSelectPrompt_EscWithoutFilter_GoBack(t *testing.T) { + cfg := tui.ResolveSelectConfig(nil) + m := NewSelectPrompt("Pick", []string{"alpha", "beta"}, cfg) + + // Esc with no filter should produce GoBackMsg + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil { + t.Fatal("expected command from Esc") + } + msg := cmd() + if _, ok := msg.(GoBackMsg); !ok { + t.Fatalf("expected GoBackMsg, got %T", msg) + } +} + +func TestSelectPrompt_CtrlC_NotHandled(t *testing.T) { + cfg := tui.ResolveSelectConfig(nil) + m := NewSelectPrompt("Pick", []string{"a", "b"}, cfg) + + // Ctrl+C should NOT be handled — composite intercepts it + updated, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + pm := updated.(PromptModel) + _, done := pm.Result() + if done { + t.Fatal("Ctrl+C should not complete the prompt") + } + // In non-wizard path, Ctrl+C sets interrupted and returns tea.Quit. + // The composite intercepts Ctrl+C before the model sees it, so this + // test verifies the model doesn't mark as "done" via Result(). + _ = cmd +} + +func TestSelectPrompt_Result_NotDoneInitially(t *testing.T) { + cfg := tui.ResolveSelectConfig(nil) + m := NewSelectPrompt("Pick", []string{"a"}, cfg) + _, done := m.Result() + if done { + t.Fatal("should not be done initially") + } +} + +// TestSelectPrompt_WithShowHints_PlumbingE2E exercises the full option +// resolution path that callers use: SelectOption → ResolveSelectConfig → +// NewSelectPrompt → View. Catches breakage between the public API and the +// model internals. +func TestSelectPrompt_WithShowHints_PlumbingE2E(t *testing.T) { + opts := []tui.SelectOption{tui.WithShowHints(true)} + cfg := tui.ResolveSelectConfig(opts) + + if !cfg.ShowHints { + t.Fatal("WithShowHints did not flow into config") + } + + m := NewSelectPrompt("Pick", []string{"a", "b"}, cfg) + view := m.View().Content + if !strings.Contains(view, "↑/↓ navigate") { + t.Errorf("hint bar missing from rendered view, got:\n%s", view) + } + if !strings.Contains(view, "ctrl+c exit") { + t.Errorf("default Hints() should include ctrl+c exit, got:\n%s", view) + } +} + +func TestSelectPrompt_WithHints_OverridePlumbingE2E(t *testing.T) { + custom := []string{"↑/↓ move", "↵ open", "q quit"} + opts := []tui.SelectOption{ + tui.WithShowHints(true), + tui.WithHints(custom...), + } + cfg := tui.ResolveSelectConfig(opts) + + m := NewSelectPrompt("Pick", []string{"a", "b"}, cfg) + + if got := m.Hints(); !equalStrings(got, custom) { + t.Errorf("Hints() = %v, want %v", got, custom) + } + view := m.View().Content + if !strings.Contains(view, "↑/↓ move · ↵ open · q quit") { + t.Errorf("override not rendered, got:\n%s", view) + } + if strings.Contains(view, "type to filter") { + t.Errorf("default hint text should be replaced by override, got:\n%s", view) + } +} + +func TestSelectPrompt_WithHints_ZeroArgsFallsBackToDefaults(t *testing.T) { + // WithHints() with no args produces a nil variadic slice — the model + // should treat that as "use defaults," not "render an empty bar." + opts := []tui.SelectOption{ + tui.WithShowHints(true), + tui.WithHints(), + } + cfg := tui.ResolveSelectConfig(opts) + m := NewSelectPrompt("Pick", []string{"a"}, cfg) + + hints := m.Hints() + if len(hints) == 0 { + t.Fatal("expected defaults, got empty") + } + if hints[0] != "↑/↓ navigate" { + t.Errorf("expected default hints, got %v", hints) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/tui/bubbletea/select_test.go b/pkg/tui/bubbletea/select_test.go new file mode 100644 index 0000000..9792d18 --- /dev/null +++ b/pkg/tui/bubbletea/select_test.go @@ -0,0 +1,485 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func TestNewSelectModel_InitialFilterState(t *testing.T) { + choices := []string{"Apple", "Banana", "Cherry"} + m := newSelectModel("Pick fruit", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + if m.filter != "" { + t.Errorf("expected empty filter, got %q", m.filter) + } + if len(m.matched) != len(choices) { + t.Errorf("expected matched length %d, got %d", len(choices), len(m.matched)) + } + for i, idx := range m.matched { + if idx != i { + t.Errorf("matched[%d] = %d, want %d", i, idx, i) + } + } +} + +func TestSelectModel_Refilter(t *testing.T) { + choices := []string{"Apple", "Apricot", "Banana", "Blueberry", "Cherry"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + // Filter by "ap" — should match Apple (0), Apricot (1) (case-insensitive) + mp := &m + mp.filter = "ap" + mp.refilter() + + if len(mp.matched) != 2 { + t.Fatalf("expected 2 matches, got %d: %v", len(mp.matched), mp.matched) + } + if mp.matched[0] != 0 || mp.matched[1] != 1 { + t.Errorf("matched = %v, want [0 1]", mp.matched) + } + if mp.cursor != 0 { + t.Errorf("cursor should reset to 0, got %d", mp.cursor) + } +} + +func TestSelectModel_RefilterNoMatch(t *testing.T) { + choices := []string{"Apple", "Banana", "Cherry"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + mp := &m + mp.filter = "zzz" + mp.refilter() + + if len(mp.matched) != 0 { + t.Errorf("expected 0 matches, got %d", len(mp.matched)) + } + if mp.cursor != 0 { + t.Errorf("cursor should be 0, got %d", mp.cursor) + } +} + +func TestSelectModel_RefilterEmpty(t *testing.T) { + choices := []string{"Apple", "Banana", "Cherry"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + mp := &m + mp.filter = "ap" + mp.refilter() + // Clear filter + mp.filter = "" + mp.refilter() + + if len(mp.matched) != 3 { + t.Errorf("expected all 3 choices back, got %d", len(mp.matched)) + } +} + +// --- Task 3: Typing/Backspace/Update tests --- + +func sendKey(m selectModel, msg tea.KeyPressMsg) selectModel { + result, _ := m.Update(msg) + return result.(selectModel) +} + +func sendRune(m selectModel, r rune) selectModel { + return sendKey(m, tea.KeyPressMsg{Code: r, Text: string(r)}) +} + +func TestSelectModel_TypeToFilter(t *testing.T) { + choices := []string{"Apple", "Apricot", "Banana", "Blueberry", "Cherry"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + m = sendRune(m, 'b') + + if m.filter != "b" { + t.Errorf("filter = %q, want %q", m.filter, "b") + } + if len(m.matched) != 2 { + t.Errorf("expected 2 matches, got %d", len(m.matched)) + } +} + +func TestSelectModel_Backspace(t *testing.T) { + choices := []string{"Apple", "Apricot", "Banana"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + m = sendRune(m, 'a') + m = sendRune(m, 'p') + + if m.filter != "ap" { + t.Fatalf("filter = %q, want %q", m.filter, "ap") + } + + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}) + + if m.filter != "a" { + t.Errorf("filter = %q after backspace, want %q", m.filter, "a") + } + if len(m.matched) != 3 { + t.Errorf("expected 3 matches, got %d", len(m.matched)) + } +} + +func TestSelectModel_VimKeysNavigateWithoutFilter(t *testing.T) { + choices := []string{"Apple", "Banana", "Cherry"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + m = sendRune(m, 'j') + if m.cursor != 1 { + t.Errorf("j should move down: cursor = %d, want 1", m.cursor) + } + if m.filter != "" { + t.Errorf("j without filter should not set filter, got %q", m.filter) + } + + m = sendRune(m, 'k') + if m.cursor != 0 { + t.Errorf("k should move up: cursor = %d, want 0", m.cursor) + } +} + +func TestSelectModel_VimKeysTypeWhenFiltering(t *testing.T) { + choices := []string{"Ajax", "Jolt", "Koji"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + // Start typing — first char is 'a', then 'j' should go into filter + m = sendRune(m, 'a') + m = sendRune(m, 'j') + + if m.filter != "aj" { + t.Errorf("filter = %q, want %q", m.filter, "aj") + } +} + +// --- Task 4: View rendering tests --- + +func TestSelectModel_ViewShowsFilter(t *testing.T) { + choices := []string{"Apple", "Apricot", "Banana"} + m := newSelectModel("Pick fruit", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + m = sendRune(m, 'a') + m = sendRune(m, 'p') + + view := m.View().Content + + if !strings.Contains(view, "ap") { + t.Errorf("view should show filter text 'ap', got:\n%s", view) + } + if !strings.Contains(view, "Apple") { + t.Error("view should show Apple") + } + if !strings.Contains(view, "Apricot") { + t.Error("view should show Apricot") + } + if strings.Contains(view, "Banana") { + t.Error("view should NOT show Banana") + } +} + +func TestSelectModel_ViewNoMatchMessage(t *testing.T) { + choices := []string{"Apple", "Banana"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + m = sendRune(m, 'z') + + view := m.View().Content + if !strings.Contains(view, "no matches") { + t.Errorf("view should show 'no matches' message, got:\n%s", view) + } +} + +// --- Task 5: Return value mapping test --- + +func TestSelectModel_ChosenReturnsOriginalIndex(t *testing.T) { + choices := []string{"Apple", "Apricot", "Banana", "Blueberry", "Cherry"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + m = sendRune(m, 'b') + m = sendRune(m, 'l') + + if len(m.matched) != 1 { + t.Fatalf("expected 1 match, got %d: %v", len(m.matched), m.matched) + } + + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if !m.chosen { + t.Fatal("expected chosen=true") + } + originalIdx := m.matched[m.cursor] + if originalIdx != 3 { + t.Errorf("original index = %d, want 3 (Blueberry)", originalIdx) + } +} + +// --- Task 6: Edge case tests --- + +func TestNewSelectModel_DefaultIndex(t *testing.T) { + choices := []string{"Apple", "Banana", "Cherry"} + m := newSelectModel("Pick", choices, tui.SelectConfig{ + Default: 2, + PageSize: 10, + Loop: true, + }) + + if m.cursor != 2 { + t.Errorf("cursor = %d, want 2", m.cursor) + } +} + +func TestSelectModel_EscClearsFilterThenAborts(t *testing.T) { + choices := []string{"Apple", "Banana"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + m = sendRune(m, 'a') + + // First Esc — clears filter + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyEscape}) + + if m.filter != "" { + t.Errorf("filter should be cleared, got %q", m.filter) + } + if m.aborted { + t.Error("should not be aborted after first Esc") + } + if len(m.matched) != 2 { + t.Errorf("all choices should be restored, got %d", len(m.matched)) + } + + // Second Esc — aborts + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyEscape}) + + if !m.aborted { + t.Error("should be aborted after second Esc") + } +} + +func TestSelectModel_EnterWithNoMatchesDoesNothing(t *testing.T) { + choices := []string{"Apple", "Banana"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + for _, r := range "zzz" { + m = sendRune(m, r) + } + + result, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = result.(selectModel) + + if m.chosen { + t.Error("should not be chosen when no matches") + } + if cmd != nil { + t.Error("should not quit when no matches") + } +} + +func TestSelectModel_NavigationWrapsInFilteredList(t *testing.T) { + choices := []string{"Apple", "Apricot", "Banana", "Avocado"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + // "ap" matches Apple(0), Apricot(1) only + m = sendRune(m, 'a') + m = sendRune(m, 'p') + + if len(m.matched) != 2 { + t.Fatalf("expected 2 matches, got %d", len(m.matched)) + } + + down := tea.KeyPressMsg{Code: tea.KeyDown} + m = sendKey(m, down) + + if m.cursor != 1 { + t.Errorf("cursor = %d, want 1", m.cursor) + } + + // Down again should wrap to 0 + m = sendKey(m, down) + + if m.cursor != 0 { + t.Errorf("cursor = %d after wrap, want 0", m.cursor) + } +} + +func TestSelectModel_BackspaceUnicode(t *testing.T) { + choices := []string{"東京タワー", "大阪城", "富士山"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + // Type "東京" (2 runes, 6 bytes each) + m = sendRune(m, '東') + m = sendRune(m, '京') + + if m.filter != "東京" { + t.Fatalf("filter = %q, want '東京'", m.filter) + } + + // Backspace should remove one rune (京), not one byte + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}) + + if m.filter != "東" { + t.Errorf("filter = %q after backspace, want '東'", m.filter) + } +} + +func TestSelectModel_BackspaceOnEmptyFilterIsNoop(t *testing.T) { + choices := []string{"Apple", "Banana"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}) + + if m.filter != "" { + t.Errorf("filter should still be empty, got %q", m.filter) + } + if len(m.matched) != 2 { + t.Errorf("matched should still have all choices, got %d", len(m.matched)) + } +} + +func TestSelectModel_ArrowKeysOnEmptyMatches(t *testing.T) { + choices := []string{"Apple", "Banana"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + for _, r := range "zzz" { + m = sendRune(m, r) + } + if len(m.matched) != 0 { + t.Fatalf("expected 0 matches, got %d", len(m.matched)) + } + + // Arrow keys should be no-ops, not panic + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyUp}) + + if m.cursor != 0 { + t.Errorf("cursor should remain 0, got %d", m.cursor) + } +} + +func TestSelectModel_View_HintBar(t *testing.T) { + const defaultBar = "↑/↓ navigate · type to filter · enter select · esc back · ctrl+c exit" + tests := []struct { + name string + cfg tui.SelectConfig + wantContain []string // substrings that must appear in view + wantAbsent []string // substrings that must NOT appear + }{ + { + name: "hints off by default", + cfg: tui.SelectConfig{}, + wantAbsent: []string{"navigate", "ctrl+c exit"}, + }, + { + name: "ShowHints renders default bar", + cfg: tui.SelectConfig{ShowHints: true}, + wantContain: []string{defaultBar}, + }, + { + name: "WithHints overrides defaults", + cfg: tui.SelectConfig{ShowHints: true, Hints: []string{"↑/↓ move", "↵ pick", "esc cancel"}}, + wantContain: []string{"↑/↓ move · ↵ pick · esc cancel"}, + wantAbsent: []string{"navigate", "type to filter", "ctrl+c exit"}, + }, + { + name: "single-element override", + cfg: tui.SelectConfig{ShowHints: true, Hints: []string{"q quit"}}, + wantContain: []string{"q quit"}, + wantAbsent: []string{" · "}, + }, + { + name: "override ignored when ShowHints off", + cfg: tui.SelectConfig{Hints: []string{"q quit"}}, + wantAbsent: []string{"q quit", "navigate"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := newSelectModel("Pick one", []string{"a", "b"}, tt.cfg) + view := m.View().Content + for _, s := range tt.wantContain { + if !strings.Contains(view, s) { + t.Errorf("expected view to contain %q, got:\n%s", s, view) + } + } + for _, s := range tt.wantAbsent { + if strings.Contains(view, s) { + t.Errorf("expected view to NOT contain %q, got:\n%s", s, view) + } + } + }) + } +} + +func TestSelectModel_HintBar_NoMatchesPathStillRenders(t *testing.T) { + m := newSelectModel("Pick", []string{"alpha", "beta"}, tui.SelectConfig{ShowHints: true}) + m = sendRune(m, 'z') + view := m.View().Content + if !strings.Contains(view, "no matches") { + t.Fatal("expected no-matches placeholder") + } + if !strings.Contains(view, "↑/↓ navigate") { + t.Errorf("hint bar must still render under no-matches state, got:\n%s", view) + } +} + +func TestSelectModel_HintBar_AbsentInChosenView(t *testing.T) { + m := newSelectModel("Pick", []string{"alpha", "beta"}, tui.SelectConfig{ShowHints: true}) + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + view := m.View().Content + if !m.chosen { + t.Fatal("expected chosen=true") + } + if strings.Contains(view, "navigate") { + t.Errorf("chosen view must not render hint bar, got:\n%s", view) + } +} + +func TestSelectModel_HintBar_AtBottomBelowChoices(t *testing.T) { + m := newSelectModel("Pick", []string{"alpha", "beta"}, tui.SelectConfig{ShowHints: true}) + view := m.View().Content + choicesAt := strings.Index(view, "alpha") + hintsAt := strings.Index(view, "↑/↓ navigate") + if choicesAt < 0 || hintsAt < 0 { + t.Fatalf("expected both choices and hint bar in view, got:\n%s", view) + } + if hintsAt <= choicesAt { + t.Errorf("hint bar should be after choices: choices@%d hints@%d", choicesAt, hintsAt) + } +} + +func TestSelectModel_ViewChosenWithFilter(t *testing.T) { + choices := []string{"Apple", "Apricot", "Banana", "Blueberry"} + m := newSelectModel("Pick", choices, tui.SelectConfig{PageSize: 10, Loop: true}) + + m = sendRune(m, 'b') + m = sendRune(m, 'l') + m = sendKey(m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if !m.chosen { + t.Fatal("expected chosen=true") + } + + view := m.View().Content + if !strings.Contains(view, "Blueberry") { + t.Errorf("chosen view should show Blueberry, got:\n%s", view) + } + if strings.Contains(view, "bl") && !strings.Contains(view, "Blueberry") { + t.Error("chosen view should show selected value, not filter text") + } +} diff --git a/pkg/tui/bubbletea/spinner.go b/pkg/tui/bubbletea/spinner.go new file mode 100644 index 0000000..1a6044a --- /dev/null +++ b/pkg/tui/bubbletea/spinner.go @@ -0,0 +1,161 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + "os" + "sync" + + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// --- Messages --- + +type spinnerUpdateMsg struct{ message string } +type spinnerStopMsg struct{ finalMessage string } + +// --- Model --- + +type spinnerModel struct { + spinner spinner.Model + message string + done bool + finalMessage string + doneSymbol string +} + +func newSpinnerModel(message string, cfg tui.SpinnerConfig) spinnerModel { + s := spinner.New(spinner.WithSpinner(mapSpinnerStyle(cfg.Style))) + return spinnerModel{ + spinner: s, + message: message, + doneSymbol: cfg.DoneSymbol, + } +} + +func (m spinnerModel) Init() tea.Cmd { return m.spinner.Tick } + +func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case spinnerUpdateMsg: + m.message = msg.message + return m, nil + case spinnerStopMsg: + m.done = true + if msg.finalMessage != "" { + m.finalMessage = msg.finalMessage + } else { + m.finalMessage = m.message + } + return m, tea.Quit + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case tea.KeyPressMsg: + if msg.String() == keyCtrlC { + m.done = true + m.finalMessage = m.message + // Re-raise SIGINT so whoever installed a signal handler + // (typically the wizard engine) can cancel the outer context + // and abort the underlying work — tea.Quit alone only tears + // down the spinner, leaving fn() running. + if p, err := os.FindProcess(os.Getpid()); err == nil { + _ = p.Signal(os.Interrupt) + } + return m, tea.Quit + } + } + return m, nil +} + +func (m spinnerModel) View() tea.View { + if m.done { + return tea.NewView(fmt.Sprintf("%s %s\n", m.doneSymbol, m.finalMessage)) + } + return tea.NewView(fmt.Sprintf("%s %s", m.spinner.View(), m.message)) +} + +func mapSpinnerStyle(s tui.SpinnerStyle) spinner.Spinner { + switch s { + case tui.SpinnerLine: + return spinner.Line + case tui.SpinnerMiniDot: + return spinner.MiniDot + case tui.SpinnerJump: + return spinner.Jump + case tui.SpinnerPulse: + return spinner.Pulse + case tui.SpinnerPoints: + return spinner.Points + case tui.SpinnerGlobe: + return spinner.Globe + case tui.SpinnerMoon: + return spinner.Moon + case tui.SpinnerMeter: + return spinner.Meter + case tui.SpinnerEllipsis: + return spinner.Ellipsis + default: + return spinner.Dot + } +} + +// --- Handle --- + +type spinnerHandle struct { + program *tea.Program + once sync.Once + done chan struct{} +} + +func (h *spinnerHandle) UpdateMessage(msg string) { + h.program.Send(spinnerUpdateMsg{message: msg}) +} + +func (h *spinnerHandle) Stop(finalMessage string) { + h.once.Do(func() { + h.program.Send(spinnerStopMsg{finalMessage: finalMessage}) + <-h.done + }) +} + +// Spinner implements tui.Status. +func (p *Prompter) Spinner(ctx context.Context, message string, opts ...tui.SpinnerOption) (tui.SpinnerHandle, error) { + cfg := tui.ResolveSpinnerConfig(opts) + model := newSpinnerModel(message, cfg) + + program := tea.NewProgram(model, + tea.WithInput(p.in), + tea.WithOutput(p.out), + tea.WithContext(ctx), + ) + + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = program.Run() + }() + + return &spinnerHandle{ + program: program, + done: done, + }, nil +} diff --git a/pkg/tui/bubbletea/styles.go b/pkg/tui/bubbletea/styles.go new file mode 100644 index 0000000..cde6661 --- /dev/null +++ b/pkg/tui/bubbletea/styles.go @@ -0,0 +1,250 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "image/color" + + "charm.land/lipgloss/v2" +) + +// Theme defines the color palette for all TUI prompts. +type Theme struct { + Accent color.Color // cursor, selected items, final answers + Success color.Color // prompt "?", checkmarks + Error color.Color // validation errors + Dim color.Color // unselected items + Hint color.Color // keyboard hints (brighter than Dim) + NoColor bool // when true, use bold/faint/reverse instead of ANSI colors +} + +// Built-in themes. +var ( + // ThemeDefault uses standard terminal ANSI colors. + ThemeDefault = Theme{ + Accent: lipgloss.Color("14"), // cyan + Success: lipgloss.Color("10"), // green + Error: lipgloss.Color("9"), // red + Dim: lipgloss.Color("8"), // gray + Hint: lipgloss.Color("7"), // light gray + } + + // ThemeDracula uses the Dracula color palette. + ThemeDracula = Theme{ + Accent: lipgloss.Color("#bd93f9"), // purple + Success: lipgloss.Color("#50fa7b"), // green + Error: lipgloss.Color("#ff5555"), // red + Dim: lipgloss.Color("#6272a4"), // comment + Hint: lipgloss.Color("#8592b8"), // brighter comment + } + + // ThemeCatppuccin uses the Catppuccin Mocha palette. + ThemeCatppuccin = Theme{ + Accent: lipgloss.Color("#89b4fa"), // blue + Success: lipgloss.Color("#a6e3a1"), // green + Error: lipgloss.Color("#f38ba8"), // red + Dim: lipgloss.Color("#6c7086"), // overlay0 + Hint: lipgloss.Color("#9399b2"), // overlay2 + } + + // ThemeNord uses the Nord color palette. + ThemeNord = Theme{ + Accent: lipgloss.Color("#88c0d0"), // frost + Success: lipgloss.Color("#a3be8c"), // green + Error: lipgloss.Color("#bf616a"), // red + Dim: lipgloss.Color("#4c566a"), // polar night + Hint: lipgloss.Color("#616e88"), // brighter polar night + } + + // ThemeTokyoNight uses the Tokyo Night palette. + ThemeTokyoNight = Theme{ + Accent: lipgloss.Color("#7aa2f7"), // blue + Success: lipgloss.Color("#9ece6a"), // green + Error: lipgloss.Color("#f7768e"), // red + Dim: lipgloss.Color("#565f89"), // comment + Hint: lipgloss.Color("#737aa2"), // brighter comment + } + + // ThemeGitHubLight is a light theme based on the GitHub Light palette. + ThemeGitHubLight = Theme{ + Accent: lipgloss.Color("#0969da"), // blue + Success: lipgloss.Color("#1a7f37"), // green + Error: lipgloss.Color("#cf222e"), // red + Dim: lipgloss.Color("#656d76"), // gray + Hint: lipgloss.Color("#57606a"), // darker gray + } + + // ThemeCatppuccinLatte is the light variant of the Catppuccin palette. + ThemeCatppuccinLatte = Theme{ + Accent: lipgloss.Color("#1e66f5"), // blue + Success: lipgloss.Color("#40a02b"), // green + Error: lipgloss.Color("#d20f39"), // red + Dim: lipgloss.Color("#9ca0b0"), // overlay0 + Hint: lipgloss.Color("#7c7f93"), // overlay1 + } + + // ThemeSolarizedLight uses the Solarized Light palette. + ThemeSolarizedLight = Theme{ + Accent: lipgloss.Color("#268bd2"), // blue + Success: lipgloss.Color("#859900"), // green + Error: lipgloss.Color("#dc322f"), // red + Dim: lipgloss.Color("#93a1a1"), // base1 + Hint: lipgloss.Color("#657b83"), // base00 + } + + // ThemeMonoDark is a color-free theme for dark-background terminals. + // Uses only bold, faint, and reverse attributes — no ANSI colors. + ThemeMonoDark = Theme{NoColor: true} + + // ThemeMonoLight is a color-free theme for light-background terminals. + // Uses only bold, faint, and reverse attributes — no ANSI colors. + ThemeMonoLight = Theme{NoColor: true} +) + +// activeTheme is the current theme. Defaults to ThemeDefault. +var activeTheme = ThemeDefault + +// activeThemeName tracks which theme is active by name. +var activeThemeName = "default" + +// Themes maps theme names to Theme values. +// Use this for CLI flag validation or listing available themes. +var Themes = map[string]Theme{ + "default": ThemeDefault, + "dracula": ThemeDracula, + "catppuccin": ThemeCatppuccin, + "nord": ThemeNord, + "tokyonight": ThemeTokyoNight, + "github-light": ThemeGitHubLight, + "catppuccin-latte": ThemeCatppuccinLatte, + "solarized-light": ThemeSolarizedLight, + "mono-dark": ThemeMonoDark, + "mono-light": ThemeMonoLight, +} + +// SetTheme changes the color theme for all TUI prompts. +// Call this before creating any prompts (typically in main or init). +func SetTheme(t Theme) { + activeTheme = t + // Try to resolve the name. + activeThemeName = "custom" + for name, theme := range Themes { + if theme == t { + activeThemeName = name + break + } + } + applyTheme() +} + +// SetThemeByName sets the theme by name. Returns false if the name is unknown. +func SetThemeByName(name string) bool { + t, ok := Themes[name] + if !ok { + return false + } + activeTheme = t + activeThemeName = name + applyTheme() + return true +} + +// GetTheme returns the active theme. +func GetTheme() Theme { + return activeTheme +} + +// HintStyle returns the resolved hint lipgloss.Style for the active theme. +// Use this with wizard.WithHintStyle to pass the correct style to HintBarView. +func HintStyle() lipgloss.Style { + return hintStyle +} + +// GetThemeName returns the name of the active theme +// (e.g., "default", "dracula", "catppuccin", "nord", "tokyonight", or "custom"). +func GetThemeName() string { + return activeThemeName +} + +// ThemeNames returns the names of all built-in themes. +func ThemeNames() []string { + return []string{"default", "dracula", "catppuccin", "catppuccin-latte", "nord", "tokyonight", "github-light", "solarized-light", "mono-dark", "mono-light"} +} + +// Shared styles derived from the active theme. +var ( + promptStyle lipgloss.Style + titleStyle lipgloss.Style + answerStyle lipgloss.Style + cursorStyle lipgloss.Style + selectedStyle lipgloss.Style + dimStyle lipgloss.Style + checkStyle lipgloss.Style + uncheckStyle lipgloss.Style + errorStyle lipgloss.Style + hintStyle lipgloss.Style +) + +func init() { + applyTheme() +} + +func applyTheme() { + t := activeTheme + + if t.NoColor { + applyNoColorTheme() + return + } + + promptStyle = lipgloss.NewStyle().Foreground(t.Success).Bold(true) + titleStyle = lipgloss.NewStyle().Bold(true) + answerStyle = lipgloss.NewStyle().Foreground(t.Accent) + cursorStyle = lipgloss.NewStyle().Foreground(t.Accent) + selectedStyle = lipgloss.NewStyle().Foreground(t.Accent) + dimStyle = lipgloss.NewStyle().Foreground(t.Dim) + checkStyle = lipgloss.NewStyle().Foreground(t.Success) + uncheckStyle = lipgloss.NewStyle().Foreground(t.Dim) + errorStyle = lipgloss.NewStyle().Foreground(t.Error) + hintStyle = lipgloss.NewStyle().Foreground(t.Hint).Bold(true) +} + +// applyNoColorTheme sets styles using only bold, faint, reverse, and +// underline — no ANSI color codes. Works on monochrome terminals and +// when TERM=dumb or NO_COLOR is set. +func applyNoColorTheme() { + reverse := activeThemeName == "mono-light" + + promptStyle = lipgloss.NewStyle().Bold(true) + titleStyle = lipgloss.NewStyle().Bold(true) + dimStyle = lipgloss.NewStyle().Faint(true) + hintStyle = lipgloss.NewStyle().Faint(true) + uncheckStyle = lipgloss.NewStyle().Faint(true) + errorStyle = lipgloss.NewStyle().Bold(true).Underline(true) + + if reverse { + // Light background: reverse for accent, bold for answers. + answerStyle = lipgloss.NewStyle().Reverse(true) + cursorStyle = lipgloss.NewStyle().Reverse(true) + selectedStyle = lipgloss.NewStyle().Reverse(true) + checkStyle = lipgloss.NewStyle().Reverse(true) + } else { + // Dark background: bold for accent, underline for answers. + answerStyle = lipgloss.NewStyle().Bold(true) + cursorStyle = lipgloss.NewStyle().Bold(true) + selectedStyle = lipgloss.NewStyle().Bold(true) + checkStyle = lipgloss.NewStyle().Bold(true) + } +} diff --git a/pkg/tui/bubbletea/table.go b/pkg/tui/bubbletea/table.go new file mode 100644 index 0000000..72f425e --- /dev/null +++ b/pkg/tui/bubbletea/table.go @@ -0,0 +1,63 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + + "charm.land/bubbles/v2/table" + "charm.land/lipgloss/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// Table implements tui.Status. +func (p *Prompter) Table(_ context.Context, columns []string, rows [][]string, opts ...tui.TableOption) error { + cfg := tui.ResolveTableConfig(opts) + + cols := make([]table.Column, len(columns)) + for i, c := range columns { + width := len(c) + for _, row := range rows { + if i < len(row) && len(row[i]) > width { + width = len(row[i]) + } + } + if cfg.MaxWidth > 0 && width > cfg.MaxWidth/len(columns) { + width = cfg.MaxWidth / len(columns) + } + cols[i] = table.Column{Title: c, Width: width} + } + + tableRows := make([]table.Row, len(rows)) + for i, row := range rows { + tableRows[i] = row + } + + t := table.New( + table.WithColumns(cols), + table.WithRows(tableRows), + table.WithHeight(len(rows)+1), + ) + + s := table.DefaultStyles() + s.Header = s.Header.Bold(true) + s.Selected = lipgloss.NewStyle() + t.SetStyles(s) + + _, err := fmt.Fprintln(p.out, t.View()) + return err +} diff --git a/pkg/tui/bubbletea/textinput.go b/pkg/tui/bubbletea/textinput.go new file mode 100644 index 0000000..d840971 --- /dev/null +++ b/pkg/tui/bubbletea/textinput.go @@ -0,0 +1,194 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "context" + "fmt" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +type textInputModel struct { + prompt string + textInput textinput.Model + submitted bool + aborted bool + interrupted bool // true for Ctrl+C (hard cancel), false for Esc (soft cancel) + pristine bool // true until user types first character; typing clears the pre-filled default + validate func(string) error + err error + bindings []KeyBinding[textInputModel] +} + +// DefaultTextInputBindings returns a fresh copy of the canonical +// binding set. Stable IDs: submit, esc, exit. An unlisted +// pristine-clear binding runs once on the first printable keystroke. +func DefaultTextInputBindings() []KeyBinding[textInputModel] { + return []KeyBinding[textInputModel]{ + { + ID: "submit", + Match: MatchKey(tea.KeyEnter), + Label: func(*textInputModel) string { return hintEnterEntry }, + Handle: func(m *textInputModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + if m.validate != nil { + if err := m.validate(m.textInput.Value()); err != nil { + m.err = err + return nil, true + } + } + m.submitted = true + return tea.Quit, true + }, + }, + { + ID: "esc", + Match: MatchKey(tea.KeyEscape), + Label: func(*textInputModel) string { return hintEscBack }, + Handle: func(m *textInputModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.aborted = true + return func() tea.Msg { return GoBackMsg{} }, true + }, + }, + { + ID: "exit", + Match: MatchRune('c', tea.ModCtrl), + Label: func(*textInputModel) string { return hintCtrlCExit }, + Handle: func(m *textInputModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + m.interrupted = true + return tea.Quit, true + }, + }, + { + // Clears the pre-filled default on the first printable + // keystroke so the user's input replaces it instead of + // appending. Always returns stop=false: textInput.Update + // must still see the key to insert it. + ID: "pristine-clear", + Match: func(_ tea.KeyPressMsg) bool { return true }, + Label: func(*textInputModel) string { return "" }, + Handle: func(m *textInputModel, msg tea.KeyPressMsg) (tea.Cmd, bool) { + if m.pristine { + m.pristine = false + if msg.Text != "" && msg.Mod == 0 { + m.textInput.SetValue("") + } + } + return nil, false + }, + }, + } +} + +// WithTextInputAddBindings prepends extras so they outrank the defaults. +// See WithSelectAddBindings for semantics. +func WithTextInputAddBindings(extras ...KeyBinding[textInputModel]) tui.TextInputOption { + return func(c *tui.TextInputConfig) { + existing, _ := c.ExtraBindings.([]KeyBinding[textInputModel]) + c.ExtraBindings = append(existing, extras...) + } +} + +func newTextInputModel(prompt string, cfg tui.TextInputConfig) textInputModel { + ti := textinput.New() + ti.Placeholder = cfg.Placeholder + ti.SetValue(cfg.Default) + ti.Focus() + + defaults := ApplyBindingOverrides(DefaultTextInputBindings(), cfg.RelabelByID, cfg.HiddenByID) + var bindings []KeyBinding[textInputModel] + if extras, ok := cfg.ExtraBindings.([]KeyBinding[textInputModel]); ok && len(extras) > 0 { + bindings = make([]KeyBinding[textInputModel], 0, len(extras)+len(defaults)) + bindings = append(bindings, extras...) + bindings = append(bindings, defaults...) + } else { + bindings = defaults + } + + return textInputModel{ + prompt: prompt, + textInput: ti, + pristine: cfg.Default != "", + validate: cfg.Validate, + bindings: bindings, + } +} + +func (m textInputModel) Init() tea.Cmd { return textinput.Blink } + +func (m textInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if _, ok := msg.(GoBackMsg); ok { + return m, tea.Quit // standalone mode quit; wizard composite intercepts before this + } + if key, ok := msg.(tea.KeyPressMsg); ok { + if cmd, stopped := Dispatch(&m, m.bindings, key); stopped { + return m, cmd + } + } + + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + m.err = nil + return m, cmd +} + +// Hints derives key hints from the resolved bindings. +func (m textInputModel) Hints() []string { + return HintsFor(&m, m.bindings) +} + +// Result returns the text input value after the user submits. +func (m textInputModel) Result() (any, bool) { + return m.textInput.Value(), m.submitted +} + +// NewTextInputPrompt creates a text input prompt model for use in the wizard composite. +func NewTextInputPrompt(prompt string, cfg tui.TextInputConfig) PromptModel { + return newTextInputModel(prompt, cfg) +} + +func (m textInputModel) View() tea.View { + if m.submitted { + return tea.NewView(fmt.Sprintf("%s %s %s\n", promptStyle.Render("?"), titleStyle.Render(m.prompt), answerStyle.Render(m.textInput.Value()))) + } + s := fmt.Sprintf("%s %s\n%s", promptStyle.Render("?"), titleStyle.Render(m.prompt), m.textInput.View()) + if m.err != nil { + s += fmt.Sprintf("\n %s", errorStyle.Render("✗ "+m.err.Error())) + } + return tea.NewView(s) +} + +// TextInput implements tui.Prompter. +func (p *Prompter) TextInput(ctx context.Context, prompt string, opts ...tui.TextInputOption) (string, error) { + cfg := tui.ResolveTextInputConfig(opts) + model := newTextInputModel(prompt, cfg) + + r := p.runProgram(ctx, model) + if r.interrupted { + return "", tui.ErrInterrupted + } + if r.err != nil { + return "", fmt.Errorf("text input prompt: %w", r.err) + } + + m := r.model.(textInputModel) + if m.aborted { + return "", context.Canceled + } + return m.textInput.Value(), nil +} diff --git a/pkg/tui/bubbletea/textinput_bindings_test.go b/pkg/tui/bubbletea/textinput_bindings_test.go new file mode 100644 index 0000000..ad84cca --- /dev/null +++ b/pkg/tui/bubbletea/textinput_bindings_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func TestTextInputBindings_DefaultHintsOrder(t *testing.T) { + cfg := tui.ResolveTextInputConfig(nil) + m := newTextInputModel("Name?", cfg) + got := strings.Join(m.Hints(), " · ") + want := "enter submit · esc back · ctrl+c exit" + if got != want { + t.Errorf("default hint order drifted\n got: %s\nwant: %s", got, want) + } +} + +func TestTextInputBindings_EnterSubmits(t *testing.T) { + m := newTextInputModel("Name?", tui.ResolveTextInputConfig(nil)) + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if !updated.(textInputModel).submitted { + t.Error("enter should submit") + } +} + +func TestTextInputBindings_EscAborts(t *testing.T) { + m := newTextInputModel("Name?", tui.ResolveTextInputConfig(nil)) + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil { + t.Fatal("esc should produce a command") + } + if _, ok := cmd().(GoBackMsg); !ok { + t.Error("esc should produce GoBackMsg") + } +} + +func TestTextInputBindings_CtrlCInterrupts(t *testing.T) { + m := newTextInputModel("Name?", tui.ResolveTextInputConfig(nil)) + updated, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if !updated.(textInputModel).interrupted { + t.Error("ctrl+c should set interrupted") + } +} + +// Validation runs on Enter; failed validation prevents submission and +// surfaces the error in the next View. +func TestTextInputBindings_ValidationBlocksSubmit(t *testing.T) { + cfg := tui.ResolveTextInputConfig([]tui.TextInputOption{ + tui.WithDefault("bad"), + tui.WithValidation(func(s string) error { + if s == "bad" { + return errors.New("nope") + } + return nil + }), + }) + m := newTextInputModel("Name?", cfg) + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + got := updated.(textInputModel) + if got.submitted { + t.Error("validation failure should block submission") + } + if got.err == nil { + t.Error("expected validation error to be set") + } + if cmd != nil { + // shouldn't quit on validation failure + if msg := cmd(); msg != nil { + if _, isQuit := msg.(tea.QuitMsg); isQuit { + t.Error("validation failure should not quit") + } + } + } +} + +// pristine: first printable keystroke clears a pre-filled default before +// the textinput bubble appends. +func TestTextInputBindings_PristineClearsDefault(t *testing.T) { + cfg := tui.ResolveTextInputConfig([]tui.TextInputOption{tui.WithDefault("hello")}) + m := newTextInputModel("Name?", cfg) + if m.textInput.Value() != "hello" { + t.Fatalf("setup: expected default 'hello', got %q", m.textInput.Value()) + } + updated, _ := m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}) + got := updated.(textInputModel) + if got.pristine { + t.Error("pristine should be cleared after first keystroke") + } + if !strings.HasPrefix(got.textInput.Value(), "x") { + t.Errorf("first keystroke should replace default; value = %q", got.textInput.Value()) + } + if got.textInput.Value() == "hellox" { + t.Error("default was not cleared before append") + } +} + +func TestTextInputBindings_Relabel(t *testing.T) { + cfg := tui.ResolveTextInputConfig([]tui.TextInputOption{ + tui.WithTextInputRelabel("submit", "↵ save"), + }) + m := newTextInputModel("Name?", cfg) + if !containsString(m.Hints(), "↵ save") { + t.Errorf("expected relabel, got %v", m.Hints()) + } +} + +func TestTextInputBindings_Hide(t *testing.T) { + cfg := tui.ResolveTextInputConfig([]tui.TextInputOption{ + tui.WithTextInputHide("exit"), + }) + m := newTextInputModel("Name?", cfg) + if containsString(m.Hints(), "ctrl+c exit") { + t.Errorf("exit should be hidden, got %v", m.Hints()) + } + // Key still works. + updated, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if !updated.(textInputModel).interrupted { + t.Error("ctrl+c should still interrupt after hide") + } +} + +func TestTextInputBindings_AddBinding(t *testing.T) { + fired := false + help := KeyBinding[textInputModel]{ + ID: "help", + Match: MatchRune('?', tea.ModCtrl), + Label: func(*textInputModel) string { return "ctrl+? help" }, + Handle: func(_ *textInputModel, _ tea.KeyPressMsg) (tea.Cmd, bool) { + fired = true + return nil, true + }, + } + cfg := tui.ResolveTextInputConfig([]tui.TextInputOption{WithTextInputAddBindings(help)}) + m := newTextInputModel("Name?", cfg) + if !containsString(m.Hints(), "ctrl+? help") { + t.Errorf("custom label missing, got %v", m.Hints()) + } + m.Update(tea.KeyPressMsg{Code: '?', Mod: tea.ModCtrl}) + if !fired { + t.Error("custom handler did not fire") + } +} diff --git a/pkg/tui/bubbletea/textinput_prompt_test.go b/pkg/tui/bubbletea/textinput_prompt_test.go new file mode 100644 index 0000000..ec9f51e --- /dev/null +++ b/pkg/tui/bubbletea/textinput_prompt_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func TestTextInputPrompt_Hints(t *testing.T) { + cfg := tui.ResolveTextInputConfig(nil) + m := NewTextInputPrompt("Name:", cfg) + hints := m.Hints() + if len(hints) == 0 { + t.Fatal("expected hints") + } +} + +func TestTextInputPrompt_TypeAndEnter(t *testing.T) { + cfg := tui.ResolveTextInputConfig(nil) + m := NewTextInputPrompt("Name:", cfg) + + // Type "hello" + for _, ch := range "hello" { + updated, _ := m.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)}) + m = updated.(PromptModel) + } + + // Press Enter + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(PromptModel) + + val, done := m.Result() + if !done { + t.Fatal("expected done after Enter") + } + if val != "hello" { + t.Errorf("expected 'hello', got %v", val) + } +} + +func TestTextInputPrompt_EscGoBack(t *testing.T) { + cfg := tui.ResolveTextInputConfig(nil) + m := NewTextInputPrompt("Name:", cfg) + + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if cmd == nil { + t.Fatal("expected command from Esc") + } + msg := cmd() + if _, ok := msg.(GoBackMsg); !ok { + t.Fatalf("expected GoBackMsg, got %T", msg) + } +} + +func TestTextInputPrompt_Result_NotDoneInitially(t *testing.T) { + cfg := tui.ResolveTextInputConfig(nil) + m := NewTextInputPrompt("Name:", cfg) + _, done := m.Result() + if done { + t.Fatal("should not be done initially") + } +} diff --git a/pkg/tui/default.go b/pkg/tui/default.go new file mode 100644 index 0000000..f7b6a85 --- /dev/null +++ b/pkg/tui/default.go @@ -0,0 +1,61 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import "sync" + +var ( + mu sync.Mutex + builder func(ioOpts ...func(*IO)) Prompter + statusBuilder func(ioOpts ...func(*IO)) Status +) + +// RegisterBuilder sets the factory used by Default(). Called by backend packages in init(). +// This enables swapping backends without import-path changes in calling code. +func RegisterBuilder(fn func(ioOpts ...func(*IO)) Prompter) { + mu.Lock() + defer mu.Unlock() + builder = fn +} + +// Default returns a Prompter created by the registered builder. +// Panics if no builder has been registered — import a backend package +// (e.g., _ "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea") to register one. +func Default() Prompter { + mu.Lock() + defer mu.Unlock() + if builder == nil { + panic("tui: no backend registered — import a backend package") + } + return builder() +} + +// RegisterStatusBuilder sets the factory used by DefaultStatus(). +func RegisterStatusBuilder(fn func(ioOpts ...func(*IO)) Status) { + mu.Lock() + defer mu.Unlock() + statusBuilder = fn +} + +// DefaultStatus returns a Status created by the registered builder. +// Panics if no builder has been registered. +func DefaultStatus() Status { + mu.Lock() + defer mu.Unlock() + if statusBuilder == nil { + panic("tui: no status backend registered — import a backend package") + } + return statusBuilder() +} diff --git a/pkg/tui/doc.go b/pkg/tui/doc.go new file mode 100644 index 0000000..4222616 --- /dev/null +++ b/pkg/tui/doc.go @@ -0,0 +1,18 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package tui provides a library-agnostic interface for interactive terminal +// prompts. The default implementation uses Bubbletea, but the Prompter +// interface can be satisfied by any backend. +package tui diff --git a/pkg/tui/errors.go b/pkg/tui/errors.go new file mode 100644 index 0000000..11a0003 --- /dev/null +++ b/pkg/tui/errors.go @@ -0,0 +1,21 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import "errors" + +// ErrInterrupted is returned when the user presses Ctrl+C (hard cancel). +// Distinct from context.Canceled which indicates Esc (soft cancel / go back). +var ErrInterrupted = errors.New("interrupted") diff --git a/pkg/tui/options.go b/pkg/tui/options.go new file mode 100644 index 0000000..1702b08 --- /dev/null +++ b/pkg/tui/options.go @@ -0,0 +1,69 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +// ResolveConfirmConfig applies options to a default ConfirmConfig. +func ResolveConfirmConfig(opts []ConfirmOption) ConfirmConfig { + cfg := ConfirmConfig{} + for _, o := range opts { + o(&cfg) + } + return cfg +} + +// ResolveTextInputConfig applies options to a default TextInputConfig. +func ResolveTextInputConfig(opts []TextInputOption) TextInputConfig { + cfg := TextInputConfig{} + for _, o := range opts { + o(&cfg) + } + return cfg +} + +// ResolveSelectConfig applies options to a default SelectConfig. +func ResolveSelectConfig(opts []SelectOption) SelectConfig { + cfg := SelectConfig{PageSize: 10, Loop: true} + for _, o := range opts { + o(&cfg) + } + return cfg +} + +// ResolveLiveListConfig applies options to a default LiveListConfig. +func ResolveLiveListConfig(opts []LiveListOption) LiveListConfig { + cfg := LiveListConfig{PageSize: 10, Loop: true} + for _, o := range opts { + o(&cfg) + } + return cfg +} + +// ResolveMultiSelectConfig applies options to a default MultiSelectConfig. +func ResolveMultiSelectConfig(opts []MultiSelectOption) MultiSelectConfig { + cfg := MultiSelectConfig{PageSize: 10, Loop: true} + for _, o := range opts { + o(&cfg) + } + return cfg +} + +// ResolveEditorConfig applies options to a default EditorConfig. +func ResolveEditorConfig(opts []EditorOption) EditorConfig { + cfg := EditorConfig{FileExt: ".txt", ShowHelp: true} + for _, o := range opts { + o(&cfg) + } + return cfg +} diff --git a/pkg/tui/status.go b/pkg/tui/status.go new file mode 100644 index 0000000..51f268f --- /dev/null +++ b/pkg/tui/status.go @@ -0,0 +1,62 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import "context" + +// Status provides output/feedback components for showing progress during +// async operations. Unlike Prompter (which collects input), Status shows +// animated output while work happens in the background. +type Status interface { + // Spinner starts an animated spinner with a message. Returns a handle + // to update the message or stop the spinner. The spinner runs until + // Stop() is called or the context is cancelled. + Spinner(ctx context.Context, message string, opts ...SpinnerOption) (SpinnerHandle, error) + + // Progress starts an animated progress bar. Returns a handle to update + // the percentage or stop. The bar runs until Stop() is called, the + // percentage reaches 1.0, or the context is cancelled. + Progress(ctx context.Context, message string, opts ...ProgressOption) (ProgressHandle, error) + + // Table renders a static table to the output and returns. + Table(ctx context.Context, columns []string, rows [][]string, opts ...TableOption) error + + // Pager displays content in a scrollable viewport if it overflows the + // terminal height, or prints it directly if it fits. The user navigates + // with arrow keys/j/k/pgup/pgdn and exits with q/esc. + Pager(ctx context.Context, content string, opts ...PagerOption) error +} + +// SpinnerHandle controls a running spinner. +type SpinnerHandle interface { + // UpdateMessage changes the spinner's status text. + UpdateMessage(msg string) + + // Stop stops the spinner and shows a final message. + // If finalMessage is empty, the last message is shown. + Stop(finalMessage string) +} + +// ProgressHandle controls a running progress bar. +type ProgressHandle interface { + // SetPercent sets the progress bar to a value between 0.0 and 1.0. + SetPercent(p float64) + + // Increment adds to the current percentage. + Increment(delta float64) + + // Stop stops the progress bar and shows a final message. + Stop(finalMessage string) +} diff --git a/pkg/tui/status_options.go b/pkg/tui/status_options.go new file mode 100644 index 0000000..a0a05b8 --- /dev/null +++ b/pkg/tui/status_options.go @@ -0,0 +1,187 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import "time" + +// --- Spinner Options --- + +// SpinnerStyle defines the visual style of a spinner. +type SpinnerStyle int + +const ( + SpinnerDot SpinnerStyle = iota + SpinnerLine + SpinnerMiniDot + SpinnerJump + SpinnerPulse + SpinnerPoints + SpinnerGlobe + SpinnerMoon + SpinnerMeter + SpinnerEllipsis +) + +// SpinnerOption configures a Spinner. +type SpinnerOption func(*SpinnerConfig) + +// SpinnerConfig holds resolved Spinner settings. +type SpinnerConfig struct { + Style SpinnerStyle + DoneSymbol string // symbol shown when stopped (default: "✓") + ErrorSymbol string // symbol shown on error (default: "✗") +} + +// WithSpinnerStyle sets the spinner animation style. +func WithSpinnerStyle(s SpinnerStyle) SpinnerOption { + return func(c *SpinnerConfig) { c.Style = s } +} + +// WithDoneSymbol sets the symbol shown when the spinner completes. +func WithDoneSymbol(s string) SpinnerOption { + return func(c *SpinnerConfig) { c.DoneSymbol = s } +} + +// WithErrorSymbol sets the symbol shown when the spinner stops with error. +func WithErrorSymbol(s string) SpinnerOption { + return func(c *SpinnerConfig) { c.ErrorSymbol = s } +} + +// ResolveSpinnerConfig applies options to a default SpinnerConfig. +func ResolveSpinnerConfig(opts []SpinnerOption) SpinnerConfig { + cfg := SpinnerConfig{ + Style: SpinnerDot, + DoneSymbol: "✓", + ErrorSymbol: "✗", + } + for _, o := range opts { + o(&cfg) + } + return cfg +} + +// --- Progress Options --- + +// ProgressOption configures a Progress bar. +type ProgressOption func(*ProgressConfig) + +// ProgressConfig holds resolved Progress settings. +type ProgressConfig struct { + Width int // bar width in characters (default: 40) + ShowPercent bool // show percentage text (default: true) + ColorA string // gradient start color (default: "#5A56E0") + ColorB string // gradient end color (default: "#EE6FF8") + SolidFill string // if set, uses solid fill instead of gradient + AutoStop bool // auto-stop when reaching 100% (default: true) + PollInterval time.Duration // for non-animated backends (default: 100ms) +} + +// WithProgressWidth sets the width of the progress bar. +func WithProgressWidth(w int) ProgressOption { + return func(c *ProgressConfig) { c.Width = w } +} + +// WithProgressGradient sets gradient colors for the progress bar. +func WithProgressGradient(colorA, colorB string) ProgressOption { + return func(c *ProgressConfig) { + c.ColorA = colorA + c.ColorB = colorB + c.SolidFill = "" + } +} + +// WithProgressSolidFill uses a solid color for the progress bar. +func WithProgressSolidFill(color string) ProgressOption { + return func(c *ProgressConfig) { + c.SolidFill = color + c.ColorA = "" + c.ColorB = "" + } +} + +// WithoutPercent hides the percentage text. +func WithoutPercent() ProgressOption { + return func(c *ProgressConfig) { c.ShowPercent = false } +} + +// ResolveProgressConfig applies options to a default ProgressConfig. +func ResolveProgressConfig(opts []ProgressOption) ProgressConfig { + cfg := ProgressConfig{ + Width: 40, + ShowPercent: true, + ColorA: "#5A56E0", + ColorB: "#EE6FF8", + AutoStop: true, + PollInterval: 100 * time.Millisecond, + } + for _, o := range opts { + o(&cfg) + } + return cfg +} + +// --- Table Options --- + +// TableOption configures a Table render. +type TableOption func(*TableConfig) + +// TableConfig holds resolved Table settings. +type TableConfig struct { + MaxWidth int // max table width (0 = no limit) +} + +// WithTableMaxWidth sets the maximum table width. +func WithTableMaxWidth(w int) TableOption { + return func(c *TableConfig) { c.MaxWidth = w } +} + +// ResolveTableConfig applies options to a default TableConfig. +func ResolveTableConfig(opts []TableOption) TableConfig { + cfg := TableConfig{} + for _, o := range opts { + o(&cfg) + } + return cfg +} + +// --- Pager Options --- + +// PagerOption configures a Pager. +type PagerOption func(*PagerConfig) + +// PagerConfig holds resolved Pager settings. +type PagerConfig struct { + Title string // optional title shown in the header + LineNumbers bool // show line numbers +} + +// WithPagerTitle sets a title displayed in the pager header. +func WithPagerTitle(t string) PagerOption { + return func(c *PagerConfig) { c.Title = t } +} + +// WithLineNumbers enables line numbers in the pager. +func WithLineNumbers() PagerOption { + return func(c *PagerConfig) { c.LineNumbers = true } +} + +// ResolvePagerConfig applies options to a default PagerConfig. +func ResolvePagerConfig(opts []PagerOption) PagerConfig { + cfg := PagerConfig{} + for _, o := range opts { + o(&cfg) + } + return cfg +} diff --git a/pkg/tui/testing/prompter.go b/pkg/tui/testing/prompter.go new file mode 100644 index 0000000..5d6526b --- /dev/null +++ b/pkg/tui/testing/prompter.go @@ -0,0 +1,228 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package testing provides a programmable tui.Prompter for use in tests. +// Responses are queued and returned in order. +package testing + +import ( + "context" + "fmt" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// Prompter is a test double that returns pre-configured responses. +type Prompter struct { + confirms []bool + textInputs []string + passwords []string + selects []int + multiSelects [][]int + liveLists []int + editors []string +} + +// New creates a test Prompter. +func New() *Prompter { return &Prompter{} } + +// AddConfirm queues a confirm response. +func (p *Prompter) AddConfirm(v bool) *Prompter { + p.confirms = append(p.confirms, v) + return p +} + +// AddTextInput queues a text input response. +func (p *Prompter) AddTextInput(v string) *Prompter { + p.textInputs = append(p.textInputs, v) + return p +} + +// AddPassword queues a password response. +func (p *Prompter) AddPassword(v string) *Prompter { + p.passwords = append(p.passwords, v) + return p +} + +// AddSelect queues a select response (index). +func (p *Prompter) AddSelect(v int) *Prompter { + p.selects = append(p.selects, v) + return p +} + +// AddMultiSelect queues a multi-select response (indices). +func (p *Prompter) AddMultiSelect(v []int) *Prompter { + p.multiSelects = append(p.multiSelects, v) + return p +} + +// AddLiveList queues a live-list response (index into rows). +func (p *Prompter) AddLiveList(v int) *Prompter { + p.liveLists = append(p.liveLists, v) + return p +} + +// AddEditor queues an editor response. +func (p *Prompter) AddEditor(v string) *Prompter { + p.editors = append(p.editors, v) + return p +} + +func (p *Prompter) Confirm(_ context.Context, _ string, _ ...tui.ConfirmOption) (bool, error) { + if len(p.confirms) == 0 { + return false, fmt.Errorf("testing.Prompter: no confirm responses queued") + } + v := p.confirms[0] + p.confirms = p.confirms[1:] + return v, nil +} + +func (p *Prompter) TextInput(_ context.Context, _ string, _ ...tui.TextInputOption) (string, error) { + if len(p.textInputs) == 0 { + return "", fmt.Errorf("testing.Prompter: no text input responses queued") + } + v := p.textInputs[0] + p.textInputs = p.textInputs[1:] + return v, nil +} + +func (p *Prompter) Password(_ context.Context, _ string) (string, error) { + if len(p.passwords) == 0 { + return "", fmt.Errorf("testing.Prompter: no password responses queued") + } + v := p.passwords[0] + p.passwords = p.passwords[1:] + return v, nil +} + +func (p *Prompter) Select(_ context.Context, _ string, _ []string, _ ...tui.SelectOption) (int, error) { + if len(p.selects) == 0 { + return -1, fmt.Errorf("testing.Prompter: no select responses queued") + } + v := p.selects[0] + p.selects = p.selects[1:] + return v, nil +} + +func (p *Prompter) MultiSelect(_ context.Context, _ string, _ []string, _ ...tui.MultiSelectOption) ([]int, error) { + if len(p.multiSelects) == 0 { + return nil, fmt.Errorf("testing.Prompter: no multi-select responses queued") + } + v := p.multiSelects[0] + p.multiSelects = p.multiSelects[1:] + return v, nil +} + +// LiveList returns the next queued index. The updates channel (if +// non-nil) is drained in the background so producers don't block; +// drained values are discarded. Tests asserting on update behavior +// should drive the model directly instead of going through this fake. +// +// Lifecycle contract: because the fake returns immediately, the drain +// goroutine outlives the call. Callers passing a non-nil updates channel +// MUST either cancel ctx or close updates so the goroutine can exit; +// otherwise it leaks for the lifetime of the test binary. +func (p *Prompter) LiveList(ctx context.Context, _ string, _ []tui.LiveRow, updates <-chan tui.LiveListUpdate, _ ...tui.LiveListOption) (int, error) { + if updates != nil { + go func() { + for { + select { + case <-ctx.Done(): + return + case _, ok := <-updates: + if !ok { + return + } + } + } + }() + } + if len(p.liveLists) == 0 { + return -1, fmt.Errorf("testing.Prompter: no live-list responses queued") + } + v := p.liveLists[0] + p.liveLists = p.liveLists[1:] + return v, nil +} + +func (p *Prompter) Editor(_ context.Context, _ string, _ ...tui.EditorOption) (string, error) { + if len(p.editors) == 0 { + return "", fmt.Errorf("testing.Prompter: no editor responses queued") + } + v := p.editors[0] + p.editors = p.editors[1:] + return v, nil +} + +// --- Status test doubles --- + +// SpinnerHandle is a no-op handle for testing. +type SpinnerHandle struct { + Messages []string + FinalMessage string + Stopped bool +} + +func (h *SpinnerHandle) UpdateMessage(msg string) { + h.Messages = append(h.Messages, msg) +} + +func (h *SpinnerHandle) Stop(finalMessage string) { + h.FinalMessage = finalMessage + h.Stopped = true +} + +// ProgressHandle is a no-op handle for testing. +type ProgressHandle struct { + Percent float64 + FinalMessage string + Stopped bool +} + +func (h *ProgressHandle) SetPercent(p float64) { + h.Percent = p +} + +func (h *ProgressHandle) Increment(delta float64) { + h.Percent += delta + if h.Percent > 1.0 { + h.Percent = 1.0 + } +} + +func (h *ProgressHandle) Stop(finalMessage string) { + h.FinalMessage = finalMessage + h.Stopped = true +} + +func (p *Prompter) Spinner(_ context.Context, _ string, _ ...tui.SpinnerOption) (tui.SpinnerHandle, error) { + return &SpinnerHandle{}, nil +} + +func (p *Prompter) Progress(_ context.Context, _ string, _ ...tui.ProgressOption) (tui.ProgressHandle, error) { + return &ProgressHandle{}, nil +} + +func (p *Prompter) Table(_ context.Context, _ []string, _ [][]string, _ ...tui.TableOption) error { + return nil +} + +func (p *Prompter) Pager(_ context.Context, _ string, _ ...tui.PagerOption) error { + return nil +} + +// Compile-time interface checks. +var _ tui.Prompter = (*Prompter)(nil) +var _ tui.Status = (*Prompter)(nil) +var _ tui.LiveLister = (*Prompter)(nil) diff --git a/pkg/tui/testing/prompter_test.go b/pkg/tui/testing/prompter_test.go new file mode 100644 index 0000000..9b241cc --- /dev/null +++ b/pkg/tui/testing/prompter_test.go @@ -0,0 +1,156 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testing + +import ( + "context" + "testing" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +func TestConfirm(t *testing.T) { + p := New().AddConfirm(true).AddConfirm(false) + + v, err := p.Confirm(context.Background(), "proceed?") + if err != nil || v != true { + t.Fatalf("expected true, got %v (err=%v)", v, err) + } + + v, err = p.Confirm(context.Background(), "proceed?") + if err != nil || v != false { + t.Fatalf("expected false, got %v (err=%v)", v, err) + } + + _, err = p.Confirm(context.Background(), "proceed?") + if err == nil { + t.Fatal("expected error when queue empty") + } +} + +func TestTextInput(t *testing.T) { + p := New().AddTextInput("hello") + + v, err := p.TextInput(context.Background(), "name?") + if err != nil || v != "hello" { + t.Fatalf("expected hello, got %q (err=%v)", v, err) + } +} + +func TestSelect(t *testing.T) { + p := New().AddSelect(2) + + v, err := p.Select(context.Background(), "pick one", []string{"a", "b", "c"}) + if err != nil || v != 2 { + t.Fatalf("expected 2, got %d (err=%v)", v, err) + } +} + +func TestMultiSelect(t *testing.T) { + p := New().AddMultiSelect([]int{0, 2}) + + v, err := p.MultiSelect(context.Background(), "pick many", []string{"a", "b", "c"}) + if err != nil || len(v) != 2 || v[0] != 0 || v[1] != 2 { + t.Fatalf("expected [0,2], got %v (err=%v)", v, err) + } +} + +func TestPassword(t *testing.T) { + p := New().AddPassword("secret") + + v, err := p.Password(context.Background(), "password?") + if err != nil || v != "secret" { + t.Fatalf("expected secret, got %q (err=%v)", v, err) + } +} + +func TestEditor(t *testing.T) { + p := New().AddEditor("line1\nline2") + + v, err := p.Editor(context.Background(), "edit:") + if err != nil || v != "line1\nline2" { + t.Fatalf("expected multiline, got %q (err=%v)", v, err) + } +} + +func TestSpinnerHandle(t *testing.T) { + p := New() + h, err := p.Spinner(context.Background(), "loading...") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + h.UpdateMessage("still loading...") + h.UpdateMessage("almost done...") + h.Stop("done!") + + sh := h.(*SpinnerHandle) + if len(sh.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(sh.Messages)) + } + if sh.Messages[0] != "still loading..." { + t.Fatalf("expected 'still loading...', got %q", sh.Messages[0]) + } + if sh.FinalMessage != "done!" { + t.Fatalf("expected final 'done!', got %q", sh.FinalMessage) + } + if !sh.Stopped { + t.Fatal("expected stopped") + } +} + +func TestProgressHandle(t *testing.T) { + p := New() + h, err := p.Progress(context.Background(), "downloading...") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + h.SetPercent(0.5) + ph := h.(*ProgressHandle) + if ph.Percent != 0.5 { + t.Fatalf("expected 0.5, got %f", ph.Percent) + } + + h.Increment(0.3) + if ph.Percent != 0.8 { + t.Fatalf("expected 0.8, got %f", ph.Percent) + } + + h.Stop("complete!") + if ph.FinalMessage != "complete!" { + t.Fatalf("expected 'complete!', got %q", ph.FinalMessage) + } + if !ph.Stopped { + t.Fatal("expected stopped") + } +} + +func TestTable(t *testing.T) { + p := New() + err := p.Table(context.Background(), + []string{"Name", "Status"}, + [][]string{{"api", "running"}, {"web", "stopped"}}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// Verify interface compliance. +func TestInterfaceCompliance(t *testing.T) { + var _ tui.Prompter = (*Prompter)(nil) + var _ tui.Status = (*Prompter)(nil) +} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go new file mode 100644 index 0000000..b643383 --- /dev/null +++ b/pkg/tui/tui.go @@ -0,0 +1,450 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import ( + "context" + "io" +) + +// Prompter is the core interface for interactive terminal prompts. +// Implementations must be safe for sequential use but need not be concurrent-safe. +type Prompter interface { + // Confirm asks a yes/no question and returns the boolean answer. + Confirm(ctx context.Context, prompt string, opts ...ConfirmOption) (bool, error) + + // TextInput asks the user for a single line of text. + TextInput(ctx context.Context, prompt string, opts ...TextInputOption) (string, error) + + // Password asks the user for sensitive input (masked). + Password(ctx context.Context, prompt string) (string, error) + + // Select presents a list of choices and returns the selected index. + Select(ctx context.Context, prompt string, choices []string, opts ...SelectOption) (int, error) + + // MultiSelect presents a list of choices and returns selected indices. + MultiSelect(ctx context.Context, prompt string, choices []string, opts ...MultiSelectOption) ([]int, error) + + // Editor opens a multi-line text editor and returns the result. + Editor(ctx context.Context, prompt string, opts ...EditorOption) (string, error) +} + +// LiveLister is the optional capability for prompters that support a +// live-updating picker. Kept out of Prompter to avoid breaking fakes +// and alternate engines. Callers type-assert: +// +// if ll, ok := prompter.(tui.LiveLister); ok { +// idx, err := ll.LiveList(ctx, prompt, rows, updates, opts...) +// } +// +// Contract: +// - Updates with unknown Key are dropped. +// - Multiple updates for the same Key: last-write-wins. +// - Cursor identity is preserved across updates (by Key, not index). +// - Type-to-filter matches the current Label, so rows may drop in +// or out of the filtered view as labels change. +// - Caller owns concurrency, retry, and error handling; renderer +// styles rows with Err != nil distinctly. +// - Closing the channel is optional; ctx is the cancellation primitive. +type LiveLister interface { + LiveList(ctx context.Context, prompt string, rows []LiveRow, updates <-chan LiveListUpdate, opts ...LiveListOption) (int, error) +} + +// IO holds the input/output streams for the prompter. +// This mirrors the pattern used in pkg/app for testability. +type IO struct { + In io.Reader + Out io.Writer + ErrOut io.Writer +} + +// DefaultIO returns IO wired to os.Stdin/Stdout/Stderr. +func DefaultIO() IO { + return IO{ + In: nil, // nil means os.Stdin at runtime — set by implementation + Out: nil, + ErrOut: nil, + } +} + +// --- Confirm Options --- + +// ConfirmOption configures a Confirm prompt. +type ConfirmOption func(*ConfirmConfig) + +// ConfirmConfig holds resolved Confirm settings. +type ConfirmConfig struct { + Default bool // default answer when user presses Enter + RelabelByID map[string]string // override individual binding labels by ID + HiddenByID []string // suppress these binding labels from the hint bar + ExtraBindings any // engine-specific extra/replacement bindings +} + +// WithConfirmDefault sets the default value for a confirm prompt. +func WithConfirmDefault(v bool) ConfirmOption { + return func(c *ConfirmConfig) { c.Default = v } +} + +// WithConfirmRelabel renames a binding's hint by its ID (yes-no, +// confirm, esc, exit). Unknown IDs are silently ignored. +func WithConfirmRelabel(id, label string) ConfirmOption { + return func(c *ConfirmConfig) { + if c.RelabelByID == nil { + c.RelabelByID = make(map[string]string) + } + c.RelabelByID[id] = label + } +} + +// WithConfirmHide suppresses one or more bindings from the hint bar by ID. +func WithConfirmHide(ids ...string) ConfirmOption { + return func(c *ConfirmConfig) { + c.HiddenByID = append(c.HiddenByID, ids...) + } +} + +// --- TextInput Options --- + +// TextInputOption configures a TextInput prompt. +type TextInputOption func(*TextInputConfig) + +// TextInputConfig holds resolved TextInput settings. +type TextInputConfig struct { + Default string + Placeholder string + Validate func(string) error + RelabelByID map[string]string // override individual binding labels by ID + HiddenByID []string // suppress these binding labels from the hint bar + ExtraBindings any // engine-specific extra/replacement bindings +} + +// WithDefault sets the default value for a text input. +func WithDefault(v string) TextInputOption { + return func(c *TextInputConfig) { c.Default = v } +} + +// WithPlaceholder sets the placeholder text. +func WithPlaceholder(v string) TextInputOption { + return func(c *TextInputConfig) { c.Placeholder = v } +} + +// WithValidation sets a validation function for the input. +func WithValidation(fn func(string) error) TextInputOption { + return func(c *TextInputConfig) { c.Validate = fn } +} + +// WithTextInputRelabel renames a binding's hint by its ID (submit, +// esc, exit). Unknown IDs are silently ignored. +func WithTextInputRelabel(id, label string) TextInputOption { + return func(c *TextInputConfig) { + if c.RelabelByID == nil { + c.RelabelByID = make(map[string]string) + } + c.RelabelByID[id] = label + } +} + +// WithTextInputHide suppresses one or more bindings from the hint bar by ID. +func WithTextInputHide(ids ...string) TextInputOption { + return func(c *TextInputConfig) { + c.HiddenByID = append(c.HiddenByID, ids...) + } +} + +// --- Select Options --- + +// SelectOption configures a Select prompt. +type SelectOption func(*SelectConfig) + +// SelectConfig holds resolved Select settings. +type SelectConfig struct { + Default int // index of the default selection + PageSize int // number of visible items (0 = show all) + Loop bool // wrap around at ends + ShowHints bool // render the prompt's Hints() bar below the choices + Hints []string // override hint strings entirely; nil = use prompt defaults + RelabelByID map[string]string // override individual binding labels by ID + HiddenByID []string // suppress these binding labels from the hint bar + ExtraBindings any // engine-specific extra/replacement bindings (set via bubbletea options) +} + +// WithSelectDefault sets the default selected index. +func WithSelectDefault(index int) SelectOption { + return func(c *SelectConfig) { c.Default = index } +} + +// WithPageSize sets the number of visible items. +func WithPageSize(n int) SelectOption { + return func(c *SelectConfig) { c.PageSize = n } +} + +// WithLoop enables wrapping when navigating past ends. +func WithLoop(v bool) SelectOption { + return func(c *SelectConfig) { c.Loop = v } +} + +// WithShowHints renders the prompt's Hints() bar below the choices. +// Off by default — wizard flows render hints externally, so callers +// inside a wizard step must NOT set this. +func WithShowHints(v bool) SelectOption { + return func(c *SelectConfig) { c.ShowHints = v } +} + +// WithHints replaces all hint strings (for localization or shorter +// labels). For per-binding rename use WithSelectRelabel; for +// suppression use WithSelectHide. +func WithHints(hints ...string) SelectOption { + return func(c *SelectConfig) { c.Hints = hints } +} + +// WithSelectRelabel renames a binding's hint by its ID (navigate, +// select, esc, exit, …). Unknown IDs are silently ignored. +func WithSelectRelabel(id, label string) SelectOption { + return func(c *SelectConfig) { + if c.RelabelByID == nil { + c.RelabelByID = make(map[string]string) + } + c.RelabelByID[id] = label + } +} + +// WithSelectHide suppresses the listed binding labels from the hint +// bar. The keys still trigger their handlers. +func WithSelectHide(ids ...string) SelectOption { + return func(c *SelectConfig) { + c.HiddenByID = append(c.HiddenByID, ids...) + } +} + +// --- LiveList Options --- + +// LiveRow is one row of a LiveList. Key is the stable identity used +// across updates; Label is the initial display text and what +// type-to-filter matches until replaced. +type LiveRow struct { + Key string + Label string +} + +// LiveListUpdate replaces the Label of the row identified by Key. +// Err is a signal flag — even when set, supply a meaningful Label +// ("error: rate limited") since the renderer still shows it. +type LiveListUpdate struct { + Key string + Label string + Err error +} + +// LiveListOption configures a LiveList prompt. +type LiveListOption func(*LiveListConfig) + +// LiveListConfig mirrors SelectConfig. Separate type so live-only +// fields (debounce, etc.) won't widen SelectConfig later. +type LiveListConfig struct { + Default int + PageSize int + Loop bool + ShowHints bool + Hints []string + RelabelByID map[string]string + HiddenByID []string + ExtraBindings any // engine-specific extras (set via bubbletea options) +} + +// WithLiveListDefault sets the initial cursor position. +func WithLiveListDefault(index int) LiveListOption { + return func(c *LiveListConfig) { c.Default = index } +} + +// WithLiveListPageSize sets the number of visible rows. +func WithLiveListPageSize(n int) LiveListOption { + return func(c *LiveListConfig) { c.PageSize = n } +} + +// WithLiveListLoop enables cursor wrap. +func WithLiveListLoop(v bool) LiveListOption { + return func(c *LiveListConfig) { c.Loop = v } +} + +// WithLiveListShowHints renders the prompt's Hints() bar below the +// rows. Off by default — must not be set inside a wizard step. +func WithLiveListShowHints(v bool) LiveListOption { + return func(c *LiveListConfig) { c.ShowHints = v } +} + +// WithLiveListHints replaces all hint strings. For per-binding rename +// use WithLiveListRelabel. +func WithLiveListHints(hints ...string) LiveListOption { + return func(c *LiveListConfig) { c.Hints = hints } +} + +// WithLiveListRelabel renames a binding's hint by its ID; same ID +// space as Select (navigate, select, esc, exit, …). +func WithLiveListRelabel(id, label string) LiveListOption { + return func(c *LiveListConfig) { + if c.RelabelByID == nil { + c.RelabelByID = make(map[string]string) + } + c.RelabelByID[id] = label + } +} + +// WithLiveListHide suppresses the listed binding labels from the hint +// bar; keys still trigger their handlers. +func WithLiveListHide(ids ...string) LiveListOption { + return func(c *LiveListConfig) { + c.HiddenByID = append(c.HiddenByID, ids...) + } +} + +// --- MultiSelect Options --- + +// MultiSelectOption configures a MultiSelect prompt. +type MultiSelectOption func(*MultiSelectConfig) + +// MultiSelectConfig holds resolved MultiSelect settings. +type MultiSelectConfig struct { + Defaults []int // indices selected by default + PageSize int + Loop bool + Min int // minimum required selections (0 = no minimum) + Max int // maximum allowed selections (0 = no maximum) + ShowHints bool // render the prompt's Hints() bar below the choices + Hints []string // override hint strings entirely; nil = use prompt defaults + RelabelByID map[string]string // override individual binding labels by ID + HiddenByID []string // suppress these binding labels from the hint bar + ExtraBindings any // engine-specific extra/replacement bindings (set via bubbletea options) + + // MinError / MaxError override the validation messages shown when the + // selection count is below Min or above Max. nil = library default. + MinError func(min int) string + MaxError func(max int) string +} + +// WithMultiSelectDefaults sets the default selected indices. +func WithMultiSelectDefaults(indices []int) MultiSelectOption { + return func(c *MultiSelectConfig) { c.Defaults = indices } +} + +// WithMultiSelectPageSize sets the number of visible items. +func WithMultiSelectPageSize(n int) MultiSelectOption { + return func(c *MultiSelectConfig) { c.PageSize = n } +} + +// WithMinSelections sets the minimum required selections. +func WithMinSelections(n int) MultiSelectOption { + return func(c *MultiSelectConfig) { c.Min = n } +} + +// WithMaxSelections sets the maximum allowed selections. +func WithMaxSelections(n int) MultiSelectOption { + return func(c *MultiSelectConfig) { c.Max = n } +} + +// WithMinSelectionsError overrides the message shown when the user +// confirms with fewer than Min selections. The func receives the +// configured minimum. nil (the default) uses the library message. +func WithMinSelectionsError(fn func(min int) string) MultiSelectOption { + return func(c *MultiSelectConfig) { c.MinError = fn } +} + +// WithMaxSelectionsError overrides the message shown when the user +// tries to select more than Max items. The func receives the +// configured maximum. nil (the default) uses the library message. +func WithMaxSelectionsError(fn func(max int) string) MultiSelectOption { + return func(c *MultiSelectConfig) { c.MaxError = fn } +} + +// WithMultiSelectShowHints renders the prompt's Hints() bar below the +// choices. Off by default — must not be set inside a wizard step. +func WithMultiSelectShowHints(v bool) MultiSelectOption { + return func(c *MultiSelectConfig) { c.ShowHints = v } +} + +// WithMultiSelectHints replaces all hint strings. For per-binding +// rename use WithMultiSelectRelabel. +func WithMultiSelectHints(hints ...string) MultiSelectOption { + return func(c *MultiSelectConfig) { c.Hints = hints } +} + +// WithMultiSelectRelabel renames a binding's hint by its ID (navigate, +// toggle, select-all, confirm, esc, exit). Unknown IDs are ignored. +func WithMultiSelectRelabel(id, label string) MultiSelectOption { + return func(c *MultiSelectConfig) { + if c.RelabelByID == nil { + c.RelabelByID = make(map[string]string) + } + c.RelabelByID[id] = label + } +} + +// WithMultiSelectHide suppresses the listed binding labels from the +// hint bar; keys still trigger their handlers. +func WithMultiSelectHide(ids ...string) MultiSelectOption { + return func(c *MultiSelectConfig) { + c.HiddenByID = append(c.HiddenByID, ids...) + } +} + +// --- Editor Options --- + +// EditorOption configures an Editor prompt. +type EditorOption func(*EditorConfig) + +// EditorConfig holds resolved Editor settings. +type EditorConfig struct { + Default string + FileExt string // file extension hint for syntax highlighting + ShowHelp bool + + // Hint overrides the affordance text shown in parentheses while + // editing (default: "ctrl+d to submit, esc to cancel"). "" = default. + Hint string + // NoHint suppresses the affordance line entirely (no parentheses). + // Takes precedence over Hint. + NoHint bool + // Summary overrides the post-submit summary (default: "[N lines]"). + // The func receives the submitted line count. nil = library default. + Summary func(lines int) string +} + +// WithEditorDefault sets the initial content in the editor. +func WithEditorDefault(v string) EditorOption { + return func(c *EditorConfig) { c.Default = v } +} + +// WithFileExt sets the file extension hint. +func WithFileExt(ext string) EditorOption { + return func(c *EditorConfig) { c.FileExt = ext } +} + +// WithEditorHint overrides the affordance text rendered in parentheses +// while editing. Passing "" keeps the library default; use +// WithEditorNoHint to suppress the affordance entirely. +func WithEditorHint(text string) EditorOption { + return func(c *EditorConfig) { c.Hint = text } +} + +// WithEditorNoHint suppresses the affordance line entirely (no +// parentheses are rendered). Takes precedence over WithEditorHint. +func WithEditorNoHint() EditorOption { + return func(c *EditorConfig) { c.NoHint = true } +} + +// WithEditorSummary overrides the summary shown after the user submits. +// The func receives the line count. nil keeps the library default. +func WithEditorSummary(fn func(lines int) string) EditorOption { + return func(c *EditorConfig) { c.Summary = fn } +} diff --git a/pkg/tui/wizard/bus.go b/pkg/tui/wizard/bus.go new file mode 100644 index 0000000..45ceb7b --- /dev/null +++ b/pkg/tui/wizard/bus.go @@ -0,0 +1,120 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import "reflect" + +type viewSlot struct { + id string + view View + subs map[reflect.Type]bool + last string // current render output + printed string // last output sent to terminal +} + +// MessageBus routes messages between the engine and views. +type MessageBus struct { + slots []viewSlot +} + +// NewMessageBus creates an empty message bus. +func NewMessageBus() *MessageBus { + return &MessageBus{} +} + +// Register adds a view to the bus. +func (b *MessageBus) Register(id string, v View) { + subs := make(map[reflect.Type]bool) + for _, t := range v.Subscribe() { + subs[t] = true + } + b.slots = append(b.slots, viewSlot{ + id: id, + view: v, + subs: subs, + }) +} + +// Broadcast sends a message to ALL views (engine-level events). +// Processes any published messages from views (chained delivery). +func (b *MessageBus) Broadcast(msg any) { + var pending []any + for i := range b.slots { + render, published := b.slots[i].view.Update(msg) + b.slots[i].last = render + pending = append(pending, published...) + } + b.deliverPending(pending) +} + +// Publish sends messages only to views that subscribed to those types. +// Processes any published messages from views (chained delivery). +func (b *MessageBus) Publish(from string, msgs []any) { + var pending []any + for _, msg := range msgs { + msgType := reflect.TypeOf(msg) + for i := range b.slots { + if b.slots[i].id == from { + continue + } + if b.slots[i].subs[msgType] { + render, published := b.slots[i].view.Update(msg) + b.slots[i].last = render + pending = append(pending, published...) + } + } + } + b.deliverPending(pending) +} + +// deliverPending processes chained messages (published by views during Update). +func (b *MessageBus) deliverPending(msgs []any) { + for len(msgs) > 0 { + var next []any + for _, msg := range msgs { + msgType := reflect.TypeOf(msg) + for i := range b.slots { + if b.slots[i].subs[msgType] { + render, published := b.slots[i].view.Update(msg) + b.slots[i].last = render + next = append(next, published...) + } + } + } + msgs = next + } +} + +// RenderAll returns the last rendered output of each view in order. +func (b *MessageBus) RenderAll() []string { + renders := make([]string, len(b.slots)) + for i, s := range b.slots { + renders[i] = s.last + } + return renders +} + +// RenderChanged returns outputs only for views whose render changed +// since the last call to RenderChanged. Unchanged views return "". +func (b *MessageBus) RenderChanged() []string { + renders := make([]string, len(b.slots)) + for i := range b.slots { + if b.slots[i].last != b.slots[i].printed { + renders[i] = b.slots[i].last + b.slots[i].printed = b.slots[i].last + } + } + return renders +} diff --git a/pkg/tui/wizard/bus_test.go b/pkg/tui/wizard/bus_test.go new file mode 100644 index 0000000..73ca6f5 --- /dev/null +++ b/pkg/tui/wizard/bus_test.go @@ -0,0 +1,165 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "reflect" + "testing" +) + +type testView struct { + received []any + subs []reflect.Type + output string + publishes []any +} + +func (r *testView) Update(msg any) (string, []any) { + r.received = append(r.received, msg) + return r.output, r.publishes +} + +func (r *testView) Subscribe() []reflect.Type { + return r.subs +} + +func TestBus_BroadcastReachesAllViews(t *testing.T) { + r1 := &testView{output: "r1"} + r2 := &testView{output: "r2"} + + bus := NewMessageBus() + bus.Register("r1", r1) + bus.Register("r2", r2) + + bus.Broadcast(StepChangedMsg{Current: 1, Total: 5}) + + if len(r1.received) != 1 { + t.Errorf("r1 should receive 1 message, got %d", len(r1.received)) + } + if len(r2.received) != 1 { + t.Errorf("r2 should receive 1 message, got %d", len(r2.received)) + } +} + +func TestBus_PublishRoutesToSubscribers(t *testing.T) { + type CustomMsg struct{ Value int } + + r1 := &testView{output: "r1", subs: []reflect.Type{reflect.TypeFor[CustomMsg]()}} + r2 := &testView{output: "r2"} + + bus := NewMessageBus() + bus.Register("r1", r1) + bus.Register("r2", r2) + + bus.Publish("sender", []any{CustomMsg{Value: 42}}) + + if len(r1.received) != 1 { + t.Errorf("r1 should receive CustomMsg, got %d messages", len(r1.received)) + } + if len(r2.received) != 0 { + t.Errorf("r2 should NOT receive CustomMsg, got %d messages", len(r2.received)) + } +} + +func TestBus_PublishTriggersChain(t *testing.T) { + type MsgA struct{} + type MsgB struct{} + + r1 := &testView{ + output: "r1", + subs: []reflect.Type{reflect.TypeFor[MsgA]()}, + publishes: []any{MsgB{}}, + } + r2 := &testView{ + output: "r2", + subs: []reflect.Type{reflect.TypeFor[MsgB]()}, + } + + bus := NewMessageBus() + bus.Register("r1", r1) + bus.Register("r2", r2) + + bus.Publish("external", []any{MsgA{}}) + + if len(r1.received) != 1 { + t.Errorf("r1 should receive 1 message, got %d", len(r1.received)) + } + if len(r2.received) != 1 { + t.Errorf("r2 should receive chained MsgB, got %d messages", len(r2.received)) + } +} + +func TestBus_RenderAll(t *testing.T) { + r1 := &testView{output: "line1"} + r2 := &testView{output: "line2"} + + bus := NewMessageBus() + bus.Register("r1", r1) + bus.Register("r2", r2) + + // Broadcast to trigger Update and populate last rendered output + bus.Broadcast(StepChangedMsg{Current: 1, Total: 2}) + + renders := bus.RenderAll() + if len(renders) != 2 || renders[0] != "line1" || renders[1] != "line2" { + t.Errorf("expected [line1 line2], got %v", renders) + } +} + +func TestBus_ViewToViewPubSub(t *testing.T) { + type DataReadyMsg struct{ Items int } + + loader := &testView{ + output: "", + publishes: []any{DataReadyMsg{Items: 42}}, + } + summary := &testView{ + output: "waiting", + } + + bus := NewMessageBus() + bus.Register("loader", loader) + bus.Register("summary", summary) + + // Engine broadcasts StepChanged -> loader receives -> publishes DataReadyMsg + // But summary has no subs for DataReadyMsg so it only gets the broadcast + bus.Broadcast(StepChangedMsg{Current: 1, Total: 3}) + + if len(loader.received) != 1 { + t.Errorf("loader should receive 1 message, got %d", len(loader.received)) + } + // summary receives the broadcast only (not the chained DataReadyMsg since it didn't subscribe) + if len(summary.received) != 1 { + t.Errorf("summary should receive 1 broadcast, got %d", len(summary.received)) + } + + // Now test with subscription + subscribedSummary := &testView{ + output: "updated", + subs: []reflect.Type{reflect.TypeFor[DataReadyMsg]()}, + } + bus2 := NewMessageBus() + bus2.Register("loader", loader) + bus2.Register("summary", subscribedSummary) + + // Reset loader received + loader.received = nil + bus2.Broadcast(StepChangedMsg{Current: 1, Total: 3}) + + // subscribedSummary should get broadcast + chained DataReadyMsg + if len(subscribedSummary.received) != 2 { + t.Errorf("subscribed summary should receive 2 messages (broadcast + chained), got %d", len(subscribedSummary.received)) + } +} diff --git a/pkg/tui/wizard/composite.go b/pkg/tui/wizard/composite.go new file mode 100644 index 0000000..ed3e3e1 --- /dev/null +++ b/pkg/tui/wizard/composite.go @@ -0,0 +1,175 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" +) + +const ( + // ActionNone indicates the prompt completed successfully (not a wizard action). + ActionNone Action = -1 +) + +// promptResult carries the outcome of a prompt back to the engine. +type promptResult struct { + value any + action Action // ActionExit, ActionBack, or ActionNone (success) +} + +// showPromptMsg tells the composite to swap the active prompt. +type showPromptMsg struct { + model bubbletea.PromptModel + stepMsg StepChangedMsg // broadcast to views +} + +// compositeModel is the single tea.Model that runs for the entire wizard. +type compositeModel struct { + bindings []KeyBinding + bus *MessageBus + prompt bubbletea.PromptModel + resultCh chan promptResult + hintBar compositeHintBar +} + +func newCompositeModel(bindings []KeyBinding, bus *MessageBus, resultCh chan promptResult) compositeModel { + if bus == nil { + bus = NewMessageBus() + } + var wizardHints []string + for _, b := range bindings { + if b.Label == "" { + continue + } + wizardHints = append(wizardHints, b.Label) + } + return compositeModel{ + bindings: bindings, + bus: bus, + resultCh: resultCh, + hintBar: compositeHintBar{wizardHints: wizardHints}, + } +} + +func (m *compositeModel) setPrompt(p bubbletea.PromptModel) { + m.prompt = p + if p != nil { + m.hintBar.promptHints = p.Hints() + } else { + m.hintBar.promptHints = nil + } +} + +func (m compositeModel) Init() tea.Cmd { + if m.prompt != nil { + return m.prompt.Init() + } + return nil +} + +func (m compositeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + // Check wizard-level key bindings first. + if action, ok := MatchBinding(m.bindings, msg); ok { + m.resultCh <- promptResult{action: action} + return m, nil + } + // Forward to active prompt. + if m.prompt != nil { + updated, cmd := m.prompt.Update(msg) + m.prompt = updated.(bubbletea.PromptModel) + // Re-read hints: prompt labels can be dynamic (e.g. esc flips + // to "clear filter" once filtering), so a one-time snapshot at + // setPrompt goes stale. + m.hintBar.promptHints = m.prompt.Hints() + // Check if prompt completed. + if val, done := m.prompt.Result(); done { + m.resultCh <- promptResult{value: val, action: ActionNone} + return m, nil + } + return m, cmd + } + return m, nil + + case bubbletea.GoBackMsg: + m.resultCh <- promptResult{action: ActionBack} + return m, nil + + case showPromptMsg: + m.setPrompt(msg.model) + // Broadcast step change to views. + m.bus.Broadcast(msg.stepMsg) + // Initialize the new prompt. + var cmd tea.Cmd + if m.prompt != nil { + cmd = m.prompt.Init() + } + return m, cmd + + default: + // Forward non-key messages to prompt (e.g. blink, window size). + if m.prompt != nil { + updated, cmd := m.prompt.Update(msg) + m.prompt = updated.(bubbletea.PromptModel) + return m, cmd + } + } + return m, nil +} + +func (m compositeModel) View() tea.View { + var sections []string + + // Render views from the message bus. + for _, output := range m.bus.RenderAll() { + if output != "" { + sections = append(sections, output) + } + } + + // Active prompt. + if m.prompt != nil { + sections = append(sections, m.prompt.View().Content) + } + + // Hint bar. + if hint := m.hintBar.render(); hint != "" { + sections = append(sections, hint) + } + + return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, sections...)) +} + +// compositeHintBar merges wizard-level and prompt-level hints. +type compositeHintBar struct { + wizardHints []string + promptHints []string +} + +func (h *compositeHintBar) render() string { + all := make([]string, 0, len(h.promptHints)+len(h.wizardHints)) + all = append(all, h.promptHints...) + all = append(all, h.wizardHints...) + if len(all) == 0 { + return "" + } + return " " + strings.Join(all, " · ") +} diff --git a/pkg/tui/wizard/composite_test.go b/pkg/tui/wizard/composite_test.go new file mode 100644 index 0000000..99f24b6 --- /dev/null +++ b/pkg/tui/wizard/composite_test.go @@ -0,0 +1,212 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" +) + +func TestComposite_CtrlC_ProducesExit(t *testing.T) { + resultCh := make(chan promptResult, 1) + cfg := tui.ResolveSelectConfig(nil) + prompt := bubbletea.NewSelectPrompt("Pick", []string{"a", "b"}, cfg) + + m := newCompositeModel(DefaultKeyBindings(), nil, resultCh) + m.setPrompt(prompt) + + // Send Ctrl+C + _, _ = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + + select { + case r := <-resultCh: + if r.action != ActionExit { + t.Errorf("expected ActionExit, got %v", r.action) + } + default: + t.Fatal("expected result on channel") + } +} + +func TestComposite_Enter_ForwardedToPrompt(t *testing.T) { + resultCh := make(chan promptResult, 1) + cfg := tui.ResolveSelectConfig(nil) + prompt := bubbletea.NewSelectPrompt("Pick", []string{"a"}, cfg) + + m := newCompositeModel(DefaultKeyBindings(), nil, resultCh) + m.setPrompt(prompt) + + // Enter on first item + _, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + select { + case r := <-resultCh: + if r.action != ActionNone { + t.Errorf("expected ActionNone (success), got %v", r.action) + } + if r.value != 0 { // index 0 + t.Errorf("expected value 0, got %v", r.value) + } + default: + t.Fatal("expected result on channel") + } +} + +func TestComposite_GoBackMsg_ProducesBack(t *testing.T) { + resultCh := make(chan promptResult, 1) + cfg := tui.ResolveSelectConfig(nil) + prompt := bubbletea.NewSelectPrompt("Pick", []string{"a"}, cfg) + + m := newCompositeModel(DefaultKeyBindings(), nil, resultCh) + m.setPrompt(prompt) + + // Esc with no filter → GoBackMsg + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = updated.(compositeModel) + + // The cmd from the prompt returns GoBackMsg — deliver it back + if cmd != nil { + msg := cmd() + _, _ = m.Update(msg) + } + + select { + case r := <-resultCh: + if r.action != ActionBack { + t.Errorf("expected ActionBack, got %v", r.action) + } + default: + t.Fatal("expected result on channel") + } +} + +func TestComposite_View_ContainsPrompt(t *testing.T) { + resultCh := make(chan promptResult, 1) + cfg := tui.ResolveSelectConfig(nil) + prompt := bubbletea.NewSelectPrompt("Pick color", []string{"red", "blue"}, cfg) + + m := newCompositeModel(DefaultKeyBindings(), nil, resultCh) + m.setPrompt(prompt) + + view := m.View() + if view.Content == "" { + t.Fatal("expected non-empty view") + } +} + +// TestComposite_HintBar_UsesPromptHints verifies the wizard's composite +// hint bar reads from prompt.Hints() and renders default hints (without +// duplicate ctrl+c — DefaultKeyBindings now suppresses its label). +func TestComposite_HintBar_UsesPromptHints(t *testing.T) { + resultCh := make(chan promptResult, 1) + cfg := tui.ResolveSelectConfig(nil) // ShowHints = false (wizard default) + prompt := bubbletea.NewSelectPrompt("Pick", []string{"a", "b"}, cfg) + + m := newCompositeModel(DefaultKeyBindings(), nil, resultCh) + m.setPrompt(prompt) + + view := m.View().Content + if !strings.Contains(view, "↑/↓ navigate") { + t.Errorf("wizard hint bar missing default hints, got:\n%s", view) + } + if !strings.Contains(view, "ctrl+c exit") { + t.Errorf("wizard hint bar should show ctrl+c exit (via prompt.Hints), got:\n%s", view) + } + // Must not appear twice (prompt owns it, wizard label is empty). + if strings.Count(view, "ctrl+c exit") != 1 { + t.Errorf("ctrl+c exit should appear exactly once, got %d:\n%s", + strings.Count(view, "ctrl+c exit"), view) + } + // In the wizard path, ShowHints stays false → prompt does not render + // its own internal hint bar inside View.Content. So default hints + // appear once total (from the composite). + if strings.Count(view, "↑/↓ navigate") != 1 { + t.Errorf("default hints should appear exactly once in wizard view, got %d:\n%s", + strings.Count(view, "↑/↓ navigate"), view) + } +} + +// TestComposite_HintBar_HonorsPromptOverride verifies callers can swap the +// hint text in a wizard step by passing WithHints when constructing the +// prompt — composite reads from prompt.Hints() so the override propagates. +func TestComposite_HintBar_HonorsPromptOverride(t *testing.T) { + resultCh := make(chan promptResult, 1) + custom := []string{"↑/↓ pick", "↵ go"} + cfg := tui.ResolveSelectConfig([]tui.SelectOption{tui.WithHints(custom...)}) + prompt := bubbletea.NewSelectPrompt("Pick", []string{"a"}, cfg) + + m := newCompositeModel(DefaultKeyBindings(), nil, resultCh) + m.setPrompt(prompt) + + view := m.View().Content + if !strings.Contains(view, "↑/↓ pick · ↵ go") { + t.Errorf("prompt override did not propagate to wizard hint bar, got:\n%s", view) + } + if strings.Contains(view, "↑/↓ navigate") { + t.Errorf("default hints should be replaced, got:\n%s", view) + } +} + +// TestComposite_HintBar_RefreshesOnFilter verifies the composite hint +// bar reflects dynamic prompt-hint labels as the prompt's state changes. +// The select esc binding flips "esc back" → "esc clear filter" once a +// filter is active; the composite must re-read Hints() per update rather +// than snapshotting once at setPrompt. +func TestComposite_HintBar_RefreshesOnFilter(t *testing.T) { + resultCh := make(chan promptResult, 1) + cfg := tui.ResolveSelectConfig(nil) + prompt := bubbletea.NewSelectPrompt("Pick", []string{"alpha", "beta"}, cfg) + + m := newCompositeModel(DefaultKeyBindings(), nil, resultCh) + m.setPrompt(prompt) + + view := m.View().Content + if !strings.Contains(view, "esc back") { + t.Fatalf("setup: expected 'esc back' before filtering, got:\n%s", view) + } + + // Type a character to start a filter. + updated, _ := m.Update(tea.KeyPressMsg{Code: 'a', Text: "a"}) + m = updated.(compositeModel) + + view = m.View().Content + if !strings.Contains(view, "esc clear filter") { + t.Errorf("hint bar did not refresh after filtering, got:\n%s", view) + } + if strings.Contains(view, "esc back") { + t.Errorf("stale 'esc back' label still present after filtering, got:\n%s", view) + } +} + +func TestComposite_ShowPromptMsg_SwapsPrompt(t *testing.T) { + resultCh := make(chan promptResult, 1) + + m := newCompositeModel(DefaultKeyBindings(), nil, resultCh) + + cfg := tui.ResolveSelectConfig(nil) + prompt := bubbletea.NewSelectPrompt("New prompt", []string{"x"}, cfg) + + updated, _ := m.Update(showPromptMsg{model: prompt}) + m = updated.(compositeModel) + + if m.prompt == nil { + t.Fatal("expected prompt to be set after showPromptMsg") + } +} diff --git a/pkg/tui/wizard/doc.go b/pkg/tui/wizard/doc.go new file mode 100644 index 0000000..df18f2b --- /dev/null +++ b/pkg/tui/wizard/doc.go @@ -0,0 +1,423 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package wizard provides a TUI wizard engine for interactive CLI flows. +// +// # Overview +// +// A wizard Flow is an ordered list of Steps executed sequentially. Each step +// collects one value from the user via a prompt (select, text input, confirm, +// password, or multi-select). The engine handles skip logic, back navigation, +// validation, default values, and dependency-driven choice loading. +// +// The engine uses a composable view/actor layout. Each visual view is an +// independent actor with its own mailbox — it receives typed messages and +// renders independently. The engine acts as a message bus, broadcasting +// lifecycle events and routing inter-view messages. +// +// # Quick Start +// +// var region string +// +// flow := &wizard.Flow{ +// Name: "create-vm", +// Steps: []wizard.Step{ +// { +// Name: "region", +// Prompt: wizard.SelectPrompt, +// Required: true, +// Loader: wizard.StaticChoices( +// wizard.Choice{Label: "Finland", Value: "FIN-01"}, +// wizard.Choice{Label: "Sweden", Value: "SWE-01"}, +// ), +// Setter: func(v any) { region = v.(string) }, +// }, +// }, +// } +// +// engine := wizard.NewEngine(prompter, status) +// if err := engine.Run(ctx, flow); err != nil { +// return err +// } +// fmt.Println("Selected:", region) +// +// # Engine Constructor +// +// NewEngine takes a [tui.Prompter] for collecting input and an optional +// [tui.Status] for spinners/progress during async operations: +// +// // Minimal — no spinners, default progress bar. +// engine := wizard.NewEngine(prompter, nil) +// +// // With status support and custom output. +// engine := wizard.NewEngine(prompter, status, wizard.WithOutput(os.Stderr)) +// +// The bubbletea.Prompter implements both tui.Prompter and tui.Status, so you +// can pass the same object for both: +// +// p := bubbletea.New() +// engine := wizard.NewEngine(p, p) +// +// # Steps +// +// Each Step defines what to collect and how: +// +// wizard.Step{ +// Name: "instance-type", // unique key in collected map +// Description: "Select instance type", // shown in the prompt +// Prompt: wizard.SelectPrompt, // widget type +// Required: true, // enforce non-empty +// DependsOn: []string{"region"}, // invalidate when region changes +// +// // Loader fetches choices. Receives Status for spinners and Store for shared data. +// Loader: func(ctx context.Context, p tui.Prompter, s tui.Status, store *wizard.Store) ([]wizard.Choice, error) { +// c := store.Collected() +// region := c["region"].(string) +// +// sp, _ := s.Spinner(ctx, "Loading instance types...") +// types, err := api.ListInstanceTypes(ctx, region) +// sp.Stop("Loaded") +// +// return buildChoices(types), err +// }, +// +// // Default value based on previously collected values. +// Default: func(c map[string]any) any { return "standard-2vcpu" }, +// +// // Validate before accepting. +// Validate: func(v any) error { return nil }, +// +// // Setter writes to your options struct. +// Setter: func(v any) { opts.InstanceType = v.(string) }, +// Resetter: func() { opts.InstanceType = "" }, +// +// // Skip if already provided via CLI flag. +// IsSet: func() bool { return opts.InstanceType != "" }, +// Value: func() any { return opts.InstanceType }, +// +// // Conditional skip based on earlier answers. +// ShouldSkip: func(c map[string]any) bool { return c["env"] == "dev" }, +// } +// +// # Prompt Types +// +// - [SelectPrompt] — single choice from a list (arrow keys + type-to-filter) +// - [MultiSelectPrompt] — multiple choices (space to toggle) +// - [TextInputPrompt] — free-form text +// - [ConfirmPrompt] — yes/no +// - [PasswordPrompt] — masked input +// +// # Loaders +// +// Loaders fetch choices for select/multi-select steps. They receive four arguments: +// +// - ctx — context for cancellation +// - prompter — for running sub-prompts (e.g., "Create SSH key?") +// - status — for showing spinners/progress during API calls (may be nil) +// - store — shared data layer; use store.Collected() for step values +// +// For fixed choices, use the StaticChoices helper: +// +// Loader: wizard.StaticChoices( +// wizard.Choice{Label: "Small", Value: "small"}, +// wizard.Choice{Label: "Large", Value: "large"}, +// ) +// +// For dynamic choices loaded from an API: +// +// Loader: func(ctx context.Context, _ tui.Prompter, s tui.Status, store *wizard.Store) ([]wizard.Choice, error) { +// c := store.Collected() +// region := c["region"].(string) +// +// // Show spinner while loading. +// sp, _ := s.Spinner(ctx, "Fetching sizes...") +// sizes, err := api.ListSizes(ctx, region) +// sp.Stop("Done") +// +// // Write to store — other regions can react to this. +// store.Set("cheapest", findCheapest(sizes)) +// +// return toChoices(sizes), err +// } +// +// # Dependencies +// +// When a step's DependsOn list includes another step name, the engine +// invalidates cached loader results when that dependency changes. This +// ensures choices are re-fetched when the user goes back and changes +// an earlier answer. +// +// # Back Navigation +// +// The engine automatically adds a "← Back" option to select/multi-select +// prompts when there is an editable prior step. Pressing Esc on any prompt +// also navigates back. The engine clears and resets all steps between the +// current position and the target. +// +// # Store +// +// The Store is a shared data layer accessible to loaders and regions: +// +// store.Collected() // snapshot of step name → value +// store.Get("cost") // read arbitrary data +// store.Set("cost", 3.50) // write arbitrary data +// +// Collected values are managed by the engine (set on step completion, +// cleared on back navigation). Arbitrary data is managed by your code — +// loaders can write values that custom views display. +// +// After the flow completes, read results via: +// +// engine.Collected() // same as store.Collected() +// engine.Store().Get("cost") // arbitrary data written during the flow +// +// # Layout and Views (Actor Model) +// +// The engine uses a composable actor model for rendering. The terminal is a +// vertical stack of views, each an independent actor with its own mailbox. +// The engine acts as a message bus: it broadcasts lifecycle events and routes +// inter-view messages. +// +// Default layout (when Flow.Layout is nil): +// +// ┌─────────────────────────────────────────────┐ +// │ ████████████████░░░░░░░░░░ Step 3 of 10 │ ← ProgressView (gradient bar) +// │ ? Select instance type │ ← Prompt (engine's sequential loop) +// │ > Standard-2vCPU-8GB │ +// │ Standard-4vCPU-16GB │ +// └─────────────────────────────────────────────┘ +// +// # How the Actor Model Works +// +// Each view is an actor. It receives messages, updates its internal state, +// and returns a render string. The engine only prints a view's output when +// it actually changes, so redundant renders are never written to the terminal. +// +// The message flow for one step: +// +// Engine Views +// ────── ───── +// 1. Broadcast StepChangedMsg ──→ ProgressView: renders "Step 3 of 10" (changed → print) +// ──→ CostView: no change (skip) +// 2. Run prompt (blocks for user input) +// 3. User picks a value +// 4. Broadcast CollectedChanged ─→ ProgressView: same output (unchanged → skip) +// ─→ CostView: recalculates "$3.20/hr" (changed → print) +// 5. Advance to next step +// +// Key design properties: +// +// - Views are decoupled: a CostView doesn't know about ProgressView. +// They communicate through typed messages, not direct calls. +// - Only changed output is printed: the engine tracks each view's last +// printed output and skips unchanged views. This prevents duplicate +// renders (e.g., ProgressView re-printing the same bar after a value +// change that doesn't affect it). +// - Views can publish messages to each other. The engine routes published +// messages to subscribers, enabling view-to-view communication. +// +// # View Interface +// +// A view implements two methods: +// +// type View interface { +// Update(msg any) (render string, publish []any) +// Subscribe() []reflect.Type +// } +// +// Update receives a message and returns: +// - render: the new display string (return the same string if nothing changed) +// - publish: optional messages to broadcast to other views (nil if none) +// +// Subscribe returns the message types this view listens to. Return nil to +// receive all engine broadcasts. Return specific types to also receive +// inter-view messages of those types. +// +// # Built-in Messages +// +// The engine broadcasts these messages at specific points in the flow: +// +// - [StepChangedMsg] — broadcast BEFORE each prompt. Contains Current +// (1-based step position), Total (number of steps), StepName, and a +// snapshot of Collected values. Use this for progress indicators. +// - [CollectedChangedMsg] — broadcast AFTER a step completes. Contains +// Key (step name), Value (selected value), and the updated Collected +// snapshot. Use this for reactive displays (cost, summary, etc.). +// - [StoreChangedMsg] — broadcast when a loader or view calls +// store.Set(). Contains Key and Value. Use this for displays driven +// by arbitrary data written during loading. +// +// # Progress Bar +// +// The built-in [ProgressView] uses the charmbracelet/bubbles progress +// component for gradient-colored rendering (same style as the bubbletea +// static progress example). It responds to [StepChangedMsg] and shows +// "Step X of Y" by default. Hidden for single-step flows. +// +// Default (bubbles default gradient #5A56E0 → #EE6FF8, step label): +// +// wizard.NewProgressView() +// +// Custom gradient to match your theme: +// +// wizard.NewProgressView( +// wizard.WithProgressGradient("#bd93f9", "#ff79c6"), +// ) +// +// Percentage mode (animated progress example style): +// +// wizard.NewProgressView(wizard.WithProgressPercent()) +// +// Solid fill, custom width: +// +// wizard.NewProgressView( +// wizard.WithProgressSolidFill("#50fa7b"), +// wizard.WithProgressWidth(30), +// ) +// +// # Custom Layout +// +// Custom layout with additional views: +// +// flow := &wizard.Flow{ +// Layout: []wizard.ViewDef{ +// {ID: "progress", View: wizard.NewProgressView( +// wizard.WithProgressGradient("#bd93f9", "#ff79c6"), +// )}, +// {ID: "cost", View: &CostView{}}, +// }, +// Steps: []wizard.Step{...}, +// } +// +// # Custom View Example +// +// A view that shows estimated cost, updating when collected values change: +// +// type CostView struct { +// last string +// } +// +// func (r *CostView) Update(msg any) (string, []any) { +// if m, ok := msg.(wizard.CollectedChangedMsg); ok { +// if price, ok := calculatePrice(m.Collected); ok { +// r.last = fmt.Sprintf(" Estimated cost: $%.2f/hr\n", price) +// } +// } +// return r.last, nil // unchanged output → engine skips printing +// } +// +// func (r *CostView) Subscribe() []reflect.Type { +// return nil // receive all engine broadcasts +// } +// +// # Inter-View Messaging +// +// Views can publish messages that other views subscribe to. The engine +// routes published messages to subscribers only (not broadcast to all). +// +// How it works: +// +// 1. The bus calls Update() on each view synchronously, in registration order. +// 2. Each view receives the message, checks the Go struct type, and processes +// only the types it cares about (ignoring the rest by returning unchanged output). +// 3. If a view returns published messages, the bus routes them to subscribers +// by matching the Go struct type against each view's Subscribe() list. +// +// There are no queues or channels — delivery is synchronous and immediate. +// +// # Message Ownership +// +// Message types are defined by the producer — the view that publishes the data: +// +// // CostView defines and publishes CostUpdatedMsg. +// type CostUpdatedMsg struct { +// Region string +// Price float64 +// } +// +// func (v *CostView) Update(msg any) (string, []any) { +// if m, ok := msg.(wizard.CollectedChangedMsg); ok { +// price := lookupPrice(m.Collected) +// return v.render(price), []any{CostUpdatedMsg{Price: price}} +// } +// return v.last, nil +// } +// +// Consumers subscribe to the producer's message type. They don't know which +// view produces it — they only know the struct type: +// +// // QuotaView subscribes to CostUpdatedMsg (defined by CostView). +// func (v *QuotaView) Subscribe() []reflect.Type { +// return []reflect.Type{reflect.TypeFor[CostUpdatedMsg]()} +// } +// +// func (v *QuotaView) Update(msg any) (string, []any) { +// if m, ok := msg.(CostUpdatedMsg); ok { +// return fmt.Sprintf(" Region: %s ($%.2f/hr)\n", m.Region, m.Price), nil +// } +// return v.last, nil +// } +// +// If multiple views produce different data that a consumer needs, the consumer +// subscribes to each type and combines them: +// +// func (v *SummaryView) Subscribe() []reflect.Type { +// return []reflect.Type{ +// reflect.TypeFor[CostUpdatedMsg](), // from CostView +// reflect.TypeFor[QuotaUpdatedMsg](), // from QuotaView +// } +// } +// +// func (v *SummaryView) Update(msg any) (string, []any) { +// switch m := msg.(type) { +// case CostUpdatedMsg: +// v.price = m.Price +// case QuotaUpdatedMsg: +// v.remaining = m.Remaining +// } +// return v.render(), nil +// } +// +// Messages chain: if a view publishes messages in response to a received +// message, those are delivered to their subscribers in the same cycle. +// +// See the wizard-views example for a complete working demonstration: +// +// go run ./pkg/tui/examples/wizard-views +// +// # Pager +// +// For displaying long content (lists, logs, details), use [tui.Status.Pager]: +// +// status.Pager(ctx, content) +// status.Pager(ctx, content, tui.WithPagerTitle("Volumes in trash")) +// +// The pager auto-detects: if content fits the terminal, it prints directly. +// If it overflows, it shows an interactive scrollable viewport (alt-screen) +// with arrow keys/j/k/pgup/pgdn navigation and q/esc to exit. +// +// # Step Lifecycle +// +// Each step goes through one of these states: +// +// - Pending — not yet visited +// - Fixed — IsSet() returned true, value provided via flag/config (skipped) +// - Skipped — ShouldSkip() returned true (skipped) +// - AutoSkipped — loader returned empty choices for optional step (skipped) +// - Completed — user answered or default applied +// +// Only Completed and Fixed steps appear in Collected(). The progress bar +// uses absolute step position (Step 3 of 12), so the total is stable and +// the bar always advances — even when steps are skipped. +package wizard diff --git a/pkg/tui/wizard/engine.go b/pkg/tui/wizard/engine.go new file mode 100644 index 0000000..4656590 --- /dev/null +++ b/pkg/tui/wizard/engine.go @@ -0,0 +1,867 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "context" + "fmt" + "io" + "os" + "os/signal" + "slices" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" +) + +// stepState represents the lifecycle state of a step during execution. +type stepState int + +const ( + statePending stepState = iota // not yet visited + stateFixed // IsSet=true, value from flag/config + stateSkipped // ShouldSkip returned true + stateAutoSkipped // loader returned empty choices (optional step) + stateCompleted // user answered or default applied +) + +// stepRuntime holds per-step execution state. +type stepRuntime struct { + state stepState + value any // the collected value + choices []Choice // cached loader results + loaded bool // true if loader has been called (distinguishes nil from not-loaded) + rewindCount int // how many times auto-rewind has targeted this step +} + +// maxRewindsPerStep is the maximum number of times the engine will auto-rewind +// to the same step before giving up with an error. +const maxRewindsPerStep = 3 + +// backLabel is appended to Select/MultiSelect choices when back navigation is possible. +const backLabel = "← Back" + +// Engine runs a wizard Flow interactively. +type Engine struct { + prompter tui.Prompter + status tui.Status + store *Store + bus *MessageBus + flow *Flow // the flow being executed (set during Run) + steps []stepRuntime // per-step runtime state, indexed same as flow.Steps + current int + writer io.Writer + reader io.Reader + keyBindings []KeyBinding + exitConfirm bool // when true, Ctrl+C prompts "Exit wizard?" before exiting + resultOverride chan promptResult // test-only: bypasses composite model + program *tea.Program // the running composite program (nil in test mode) + resultCh chan promptResult // channel for receiving prompt results + + // interruptCancel lets a second Ctrl+C on the "Exit wizard?" confirm + // abort any in-flight loader ctx, not just the wizard itself. + interruptCancel context.CancelFunc +} + +// EngineOption configures the Engine. +type EngineOption func(*Engine) + +// WithOutput sets the writer for engine messages (defaults to os.Stderr). +func WithOutput(w io.Writer) EngineOption { + return func(e *Engine) { e.writer = w } +} + +// WithKeyBindings sets custom wizard-level key bindings. +func WithKeyBindings(bindings ...KeyBinding) EngineOption { + return func(e *Engine) { e.keyBindings = bindings } +} + +// WithInput sets the input reader for the composite tea.Program (defaults to os.Stdin). +func WithInput(r io.Reader) EngineOption { + return func(e *Engine) { e.reader = r } +} + +// WithExitConfirmation enables a "Exit wizard?" confirmation prompt when the +// user presses Ctrl+C. Without this option, Ctrl+C exits immediately. +func WithExitConfirmation() EngineOption { + return func(e *Engine) { e.exitConfirm = true } +} + +// TestResult represents a prompt result for testing. +type TestResult struct { + Value any + Action Action +} + +// WithTestResults configures the engine to use pre-built results instead of +// running the composite tea.Program. This is for external package tests. +func WithTestResults(results ...TestResult) EngineOption { + return func(e *Engine) { + ch := make(chan promptResult, len(results)) + for _, r := range results { + ch <- promptResult{value: r.Value, action: r.Action} + } + close(ch) + e.resultOverride = ch + } +} + +// Test result constructors for external package tests. + +// SelectResult creates a test result for selecting an index. +func SelectResult(idx int) TestResult { return TestResult{Value: idx, Action: ActionNone} } + +// TextResult creates a test result for text input. +func TextResult(text string) TestResult { return TestResult{Value: text, Action: ActionNone} } + +// ConfirmResult creates a test result for a confirm prompt. +func ConfirmResult(yes bool) TestResult { return TestResult{Value: yes, Action: ActionNone} } + +// MultiSelectResult creates a test result for multi-select. +func MultiSelectResult(indices []int) TestResult { + return TestResult{Value: indices, Action: ActionNone} +} + +// BackResult creates a test result for back navigation (Esc). +func BackResult() TestResult { return TestResult{Action: ActionBack} } + +// ExitResult creates a test result for exit (Ctrl+C). +func ExitResult() TestResult { return TestResult{Action: ActionExit} } + +// NewEngine creates a wizard engine with the given prompter and optional status. +func NewEngine(prompter tui.Prompter, status tui.Status, opts ...EngineOption) *Engine { + e := &Engine{ + prompter: prompter, + status: status, + store: NewStore(), + keyBindings: DefaultKeyBindings(), + } + for _, opt := range opts { + opt(e) + } + return e +} + +// Store returns the engine's shared store. +func (e *Engine) Store() *Store { + return e.store +} + +func (e *Engine) out() io.Writer { + if e.writer != nil { + return e.writer + } + return os.Stderr +} + +// Collected returns the values collected during the flow. +func (e *Engine) Collected() map[string]any { + return e.store.Collected() +} + +// Run executes the flow step by step. +func (e *Engine) Run(ctx context.Context, flow *Flow) error { + e.current = 0 + e.flow = flow + e.steps = make([]stepRuntime, len(flow.Steps)) + e.store.Reset() + + // Initialize message bus with layout views (or default). + e.bus = NewMessageBus() + layout := flow.Layout + if layout == nil { + layout = []ViewDef{{ID: "progress", View: NewProgressView()}} + } + for _, def := range layout { + e.bus.Register(def.ID, def.View) + } + + // Wire store change notifications to the message bus. + e.store.onChange = func(key string, value any) { + e.bus.Broadcast(StoreChangedMsg{Key: key, Value: value}) + } + + // In test mode (WithTestResults), bypass the composite program entirely. + e.resultCh = e.resultOverride + e.program = nil + + // Turn SIGINT into a context cancellation so Ctrl+C works during + // Loader execution, when the terminal is in cooked mode and no + // bubbletea program is handling key events. Without this, Go's + // default handler kills the process and leaves the terminal in raw + // mode. + if e.resultOverride == nil { + sigCtx, sigCancel := context.WithCancel(ctx) + defer sigCancel() + ctx = sigCtx + e.interruptCancel = sigCancel + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + defer signal.Stop(sigCh) + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-sigCh: + sigCancel() + // Drain further SIGINTs so repeated Ctrl+C does not + // fall through to Go's default and kill the process + // mid-cleanup, leaving the terminal in raw mode. + for { + select { + case <-sigCh: + case <-done: + return + } + } + case <-done: + return + } + }() + } + + // When a custom reader is set (WithInput, typically pipe-based tests), + // use a persistent program for the entire wizard. Pipe inputs cannot + // survive program restart because the first program may consume buffered + // bytes intended for later prompts. + // + // When using real stdin (e.reader == nil), create a fresh program per + // prompt. This prevents Loaders (which may create their own tea.Programs + // for sub-flows like prompter.Select/Spinner) from racing with the + // composite program over stdin. + if e.reader != nil && e.resultOverride == nil { + return e.runPersistentProgram(ctx) + } + return e.runPerPromptProgram(ctx) +} + +// runPersistentProgram runs the wizard with a single persistent composite +// program. Used when a custom reader is set (pipe-based tests) where +// restarting programs would lose buffered input. +func (e *Engine) runPersistentProgram(ctx context.Context) error { + e.resultCh = make(chan promptResult, 1) + composite := newCompositeModel(e.keyBindings, e.bus, e.resultCh) + progOpts := []tea.ProgramOption{ + tea.WithoutSignalHandler(), + tea.WithOutput(e.out()), + tea.WithInput(e.reader), + } + e.program = tea.NewProgram(&composite, progOpts...) + progDone := make(chan struct{}) + go func() { + defer close(progDone) + _, _ = e.program.Run() + }() + defer func() { + e.program.Quit() + <-progDone + }() + + return e.stepLoop(ctx) +} + +// runPerPromptProgram runs the wizard creating a fresh composite program for +// each prompt. Loaders execute with no program running, so they can safely +// create their own tea.Programs (spinners, sub-flow prompts) without stdin +// conflicts. +func (e *Engine) runPerPromptProgram(ctx context.Context) error { + return e.stepLoop(ctx) +} + +// stepLoop is the main wizard loop shared by both program modes. +func (e *Engine) stepLoop(ctx context.Context) error { + for e.current < len(e.flow.Steps) { + step := e.flow.Steps[e.current] + col := e.store.Collected() + + // ShouldSkip takes priority over IsSet. + if step.ShouldSkip != nil && step.ShouldSkip(col) { + e.transition(e.current, stateSkipped, nil) + e.current++ + continue + } + + if step.IsSet != nil && step.IsSet() { + if err := e.handleFixed(step); err != nil { + return err + } + e.current++ + continue + } + + choices, err := e.loadChoices(ctx, step) + if err != nil { + return fmt.Errorf("step %q: %w", step.Name, err) + } + + if step.Loader != nil && len(choices) == 0 { + handled, err := e.handleEmptyChoices(step) + if err != nil { + return err + } + if handled { + continue + } + } + + // Build prompt model and send to composite. + canGoBack := e.hasEditablePriorStep() + promptModel := e.buildPromptModel(step, choices, canGoBack) + if promptModel == nil { + return fmt.Errorf("step %q: unsupported prompt type: %d", step.Name, step.Prompt) + } + + // In per-prompt mode (no persistent program), start a fresh program. + perPrompt := e.program == nil && e.resultOverride == nil + var progDone chan struct{} + if perPrompt { + progDone = e.startProgram() + } + + if e.program != nil { + e.program.Send(showPromptMsg{ + model: promptModel, + stepMsg: StepChangedMsg{ + Current: e.current + 1, + Total: len(e.flow.Steps), + StepName: step.Name, + PromptType: step.Prompt, + Collected: e.store.Collected(), + }, + }) + } + + // Wait for result from composite and process it. + // handlePromptResult may call confirmExit which reuses the program. + result := <-e.resultCh + done, err := e.handlePromptResult(result, step, choices, canGoBack) + + if perPrompt { + e.stopProgram(progDone) + } + + if err != nil { + return err + } + if done { + e.current++ + } + } + return nil +} + +// startProgram creates and starts a new composite tea.Program for one prompt. +// Returns a channel that closes when the program exits. +// In test mode (resultOverride set), this is a no-op. +func (e *Engine) startProgram() chan struct{} { + if e.resultOverride != nil { + return nil // test mode — no real program + } + + e.resultCh = make(chan promptResult, 1) + composite := newCompositeModel(e.keyBindings, e.bus, e.resultCh) + progOpts := []tea.ProgramOption{ + tea.WithoutSignalHandler(), + tea.WithOutput(e.out()), + } + if e.reader != nil { + progOpts = append(progOpts, tea.WithInput(e.reader)) + } + e.program = tea.NewProgram(&composite, progOpts...) + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = e.program.Run() + }() + return done +} + +// stopProgram quits the composite program and waits for it to fully exit +// so the terminal is restored before the next Loader or prompt. +func (e *Engine) stopProgram(done chan struct{}) { + if e.program == nil { + return + } + e.program.Quit() + <-done + e.program = nil +} + +// handlePromptResult processes the result from a prompt. +// Returns (true, nil) when the step is completed and the engine should advance. +// Returns (false, nil) when the engine should re-prompt or rewind (current adjusted internally). +// Returns (false, err) on fatal error. +func (e *Engine) handlePromptResult(result promptResult, step Step, choices []Choice, canGoBack bool) (bool, error) { + switch result.action { + case ActionExit: + if e.exitConfirm && e.program != nil { + if stayed := e.confirmExit(); stayed { + return false, nil // re-prompt current step + } + } + _, _ = fmt.Fprintln(e.out()) + return false, fmt.Errorf("wizard cancelled") + case ActionBack: + if canGoBack { + e.rewindOne() + } else { + _, _ = fmt.Fprintln(e.out()) + return false, fmt.Errorf("wizard cancelled") + } + return false, nil + } + + // ActionNone — prompt completed with a value. + value := result.value + + // Handle "← Back" selection in select/multiselect. + if idx, ok := value.(int); ok && canGoBack && idx == len(choices) { + e.rewindOne() + return false, nil + } + + // Convert index to Choice value for select prompts. + if step.Prompt == SelectPrompt { + if idx, ok := value.(int); ok { + value = choices[idx].Value + } + } + // Convert indices to values for multiselect. + if step.Prompt == MultiSelectPrompt { + if indices, ok := value.([]int); ok { + values := make([]string, len(indices)) + for i, idx := range indices { + values[i] = choices[idx].Value + } + value = values + } + } + + // Apply default for non-required empty values. + col := e.store.Collected() + if !step.Required && isEmpty(value) && step.Default != nil { + value = step.Default(col) + } + + // Enforce required. + if step.Required && isEmpty(value) { + return false, nil // re-prompt + } + + // Validate. + if step.Validate != nil { + if err := step.Validate(value); err != nil { + return false, nil // re-prompt + } + } + + // Complete the step. + if step.Setter != nil { + step.Setter(value) + } + e.transition(e.current, stateCompleted, value) + + e.bus.Broadcast(CollectedChangedMsg{ + Key: step.Name, + Value: value, + Collected: e.store.Collected(), + }) + + e.invalidateDownstream(e.current) + return true, nil +} + +// buildPromptModel creates the appropriate PromptModel for a step. +func (e *Engine) buildPromptModel(step Step, choices []Choice, canGoBack bool) bubbletea.PromptModel { + switch step.Prompt { + case SelectPrompt: + labels := choiceLabels(choices) + if canGoBack { + labels = append(labels, backLabel) + } + var opts []tui.SelectOption + if step.Default != nil { + col := e.store.Collected() + if defVal, ok := step.Default(col).(string); ok { + for i, c := range choices { + if c.Value == defVal { + opts = append(opts, tui.WithSelectDefault(i)) + break + } + } + } + } + cfg := tui.ResolveSelectConfig(opts) + return bubbletea.NewSelectPrompt(promptLabel(step), labels, cfg) + case MultiSelectPrompt: + labels := choiceLabels(choices) + if canGoBack { + labels = append(labels, backLabel) + } + var opts []tui.MultiSelectOption + if step.Required { + opts = append(opts, tui.WithMinSelections(1)) + } + if step.MinError != nil { + opts = append(opts, tui.WithMinSelectionsError(step.MinError)) + } + if step.Default != nil { + col := e.store.Collected() + if defVals, ok := step.Default(col).([]string); ok && len(defVals) > 0 { + valSet := make(map[string]bool, len(defVals)) + for _, v := range defVals { + valSet[v] = true + } + var defaults []int + for i, c := range choices { + if valSet[c.Value] { + defaults = append(defaults, i) + } + } + if len(defaults) > 0 { + opts = append(opts, tui.WithMultiSelectDefaults(defaults)) + } + } + } + cfg := tui.ResolveMultiSelectConfig(opts) + return bubbletea.NewMultiSelectPrompt(promptLabel(step), labels, cfg) + case TextInputPrompt: + var opts []tui.TextInputOption + if step.Default != nil { + col := e.store.Collected() + if d, ok := step.Default(col).(string); ok && d != "" { + opts = append(opts, tui.WithDefault(d)) + } + } + cfg := tui.ResolveTextInputConfig(opts) + return bubbletea.NewTextInputPrompt(promptLabel(step), cfg) + case ConfirmPrompt: + var opts []tui.ConfirmOption + if step.Default != nil { + col := e.store.Collected() + if d, ok := step.Default(col).(bool); ok { + opts = append(opts, tui.WithConfirmDefault(d)) + } + } + cfg := tui.ResolveConfirmConfig(opts) + return bubbletea.NewConfirmPrompt(promptLabel(step), cfg) + case PasswordPrompt: + return bubbletea.NewPasswordPrompt(promptLabel(step)) + default: + return nil + } +} + +// handleFixed processes a step with IsSet=true. +func (e *Engine) handleFixed(step Step) error { + if step.Value != nil { + val := step.Value() + if step.Validate != nil { + if err := step.Validate(val); err != nil { + return fmt.Errorf("step %q: preset value invalid: %w", step.Name, err) + } + } + if step.Setter != nil { + step.Setter(val) + } + e.steps[e.current].state = stateFixed + e.steps[e.current].value = val + e.store.SetCollected(step.Name, val) + } else { + e.steps[e.current].state = stateFixed + } + return nil +} + +// handleEmptyChoices handles the case when a step's loader returns no choices. +func (e *Engine) handleEmptyChoices(step Step) (bool, error) { + col := e.store.Collected() + if !step.Required { + if step.Default != nil { + val := step.Default(col) + if step.Setter != nil { + step.Setter(val) + } + e.transition(e.current, stateAutoSkipped, val) + } else { + e.transition(e.current, stateAutoSkipped, nil) + } + e.current++ + return true, nil + } + + if e.current == 0 { + return false, fmt.Errorf("step %q: no options available and cannot go back", step.Name) + } + + e.steps[e.current].rewindCount++ + if e.steps[e.current].rewindCount > maxRewindsPerStep { + return false, fmt.Errorf("step %q: no options available after %d attempts — the flow cannot proceed with current inputs", step.Name, maxRewindsPerStep) + } + + _, _ = fmt.Fprintf(e.out(), " No options available for %q — going back.\n", promptLabel(step)) + if err := e.rewindToDependency(step); err != nil { + return false, fmt.Errorf("step %q: %w", step.Name, err) + } + return true, nil +} + +// --- State transitions --- + +// transition sets a step to a new state, resetting the caller-bound variable if needed. +func (e *Engine) transition(idx int, newState stepState, value any) { + rt := &e.steps[idx] + step := e.flow.Steps[idx] + + // Call Resetter when clearing a step's value (moving to pending, skipped, + // or auto-skipped without a value). This ensures the caller-bound variable + // is always consistent with the engine state. + shouldReset := (newState == statePending || newState == stateSkipped) || + (newState == stateAutoSkipped && value == nil) + if shouldReset && step.Resetter != nil { + step.Resetter() + } + + rt.state = newState + rt.value = value + rt.choices = nil // invalidate cached choices + rt.loaded = false + rt.rewindCount = 0 // reset guard so revisits get fresh attempts + + // Keep store in sync. + if value != nil { + e.store.SetCollected(step.Name, value) + } else { + e.store.ClearCollected(step.Name) + } +} + +// resetRange resets all non-fixed steps in [from, to) to statePending. +func (e *Engine) resetRange(from, to int) { + for i := from; i < to; i++ { + if e.steps[i].state == stateFixed { + continue // preserve preset values + } + e.transition(i, statePending, nil) + } +} + +// --- Navigation --- + +// rewindOne goes back to the nearest editable prior step, clearing everything between. +func (e *Engine) rewindOne() { + from := e.current + e.current-- + for e.current >= 0 { + if e.isEditable(e.current) { + e.resetRange(e.current, from+1) // +1 to include the abandoned step + return + } + e.current-- + } + e.current = 0 +} + +// rewindToDependency goes to the nearest editable dependency, or the earliest +// editable step if deps are skipped. Returns error if no rewind target exists. +func (e *Engine) rewindToDependency(current Step) error { + if len(current.DependsOn) == 0 { + e.rewindOne() + return nil + } + + depSet := make(map[string]bool, len(current.DependsOn)) + for _, d := range current.DependsOn { + depSet[d] = true + } + + // Classify dependencies. + nearestEditable := -1 + hasFixed := false + hasSkipped := false + for i := e.current - 1; i >= 0; i-- { + step := e.flow.Steps[i] + if !depSet[step.Name] { + continue + } + switch { + case e.steps[i].state == stateFixed: + hasFixed = true + case !e.isEditable(i): + hasSkipped = true + default: + if nearestEditable == -1 || i > nearestEditable { + nearestEditable = i + } + } + } + + // Direct editable dependency found. + if nearestEditable >= 0 { + e.resetRange(nearestEditable, e.current) + e.current = nearestEditable + return nil + } + + // All deps are fixed — unrecoverable. + if hasFixed && !hasSkipped { + return fmt.Errorf("step %q has no options available and all its dependencies %v are fixed (set via flag)", current.Name, current.DependsOn) + } + + // Deps are skipped — find the earliest editable step that could change + // the skip condition. + if hasSkipped { + for i := 0; i < e.current; i++ { + if e.isEditable(i) { + e.resetRange(i, e.current+1) + e.current = i + return nil + } + } + return fmt.Errorf("step %q has no options available and no editable prior step exists to change the outcome", current.Name) + } + + // Fallback. + e.rewindOne() + return nil +} + +// isEditable returns true if a step can be prompted (not fixed, not skipped, not auto-skipped). +func (e *Engine) isEditable(idx int) bool { + step := e.flow.Steps[idx] + rt := e.steps[idx] + if rt.state == stateFixed { + return false + } + col := e.store.Collected() + if step.ShouldSkip != nil && step.ShouldSkip(col) { + return false + } + if rt.state == stateAutoSkipped { + return false + } + return true +} + +// hasEditablePriorStep returns true if there is at least one earlier editable step. +func (e *Engine) hasEditablePriorStep() bool { + for i := e.current - 1; i >= 0; i-- { + if e.isEditable(i) { + return true + } + } + return false +} + +// --- Choice loading --- + +func (e *Engine) loadChoices(ctx context.Context, step Step) ([]Choice, error) { + if step.Loader == nil { + return nil, nil + } + + rt := &e.steps[e.current] + if rt.loaded { + return rt.choices, nil + } + + choices, err := step.Loader(ctx, e.prompter, e.status, e.store) + if err != nil { + return nil, err + } + rt.choices = choices + rt.loaded = true + return choices, nil +} + +func (e *Engine) invalidateDownstream(changedIdx int) { + changedName := e.flow.Steps[changedIdx].Name + for i, step := range e.flow.Steps { + if slices.Contains(step.DependsOn, changedName) { + e.steps[i].choices = nil + e.steps[i].loaded = false + } + } +} + +// --- Exit confirmation --- + +// confirmExit swaps the active prompt with a "Exit wizard?" confirm prompt. +// Returns true if the user chose to stay (declined or pressed Esc). +// +// A second Ctrl+C on the confirm force-exits rather than bouncing back +// into the flow — the user has already asked to leave once, and double +// Ctrl+C is the universal escape hatch. +func (e *Engine) confirmExit() (stayed bool) { + cfg := tui.ResolveConfirmConfig([]tui.ConfirmOption{tui.WithConfirmDefault(true)}) + confirmModel := bubbletea.NewConfirmPrompt("Exit wizard?", cfg) + + e.program.Send(showPromptMsg{ + model: confirmModel, + stepMsg: StepChangedMsg{PromptType: ConfirmPrompt}, + }) + + result := <-e.resultCh + switch result.action { + case ActionExit: + // Second Ctrl+C — also cancel any in-flight loader ctx so no + // cleanup work sneaks past the exit. + if e.interruptCancel != nil { + e.interruptCancel() + } + return false + case ActionBack: + return true + } + if confirmed, ok := result.value.(bool); ok && confirmed { + return false + } + return true +} + +// --- Utilities --- + +// promptLabel returns the display text for a step, falling back to Name if Description is empty. +func promptLabel(step Step) string { + if step.Description != "" { + return step.Description + } + return step.Name +} + +func choiceLabels(choices []Choice) []string { + labels := make([]string, len(choices)) + for i, c := range choices { + labels[i] = c.Label + } + return labels +} + +func isEmpty(v any) bool { + if v == nil { + return true + } + switch val := v.(type) { + case string: + return val == "" + case []string: + return len(val) == 0 + default: + return false + } +} diff --git a/pkg/tui/wizard/engine_test.go b/pkg/tui/wizard/engine_test.go new file mode 100644 index 0000000..cca1572 --- /dev/null +++ b/pkg/tui/wizard/engine_test.go @@ -0,0 +1,1871 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "context" + "fmt" + "io" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" + tuitesting "github.com/verda-cloud/verda-cli/pkg/tui/testing" +) + +// newTestEngine creates an engine with a resultOverride channel for unit testing. +// This bypasses the composite model — results are read directly from the channel. +func newTestEngine(results []promptResult, opts ...EngineOption) *Engine { + p := tuitesting.New() + e := NewEngine(p, nil, opts...) + e.resultOverride = testResultCh(results...) + return e +} + +func TestEngine_HappyPath_AllStepsPrompted(t *testing.T) { + var region, gpu string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Description: "Select region", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "Finland", Value: "FIN-01"}, + Choice{Label: "Sweden", Value: "SWE-01"}, + ), + Setter: func(v any) { region = v.(string) }, + }, + { + Name: "gpu", + Description: "Select GPU", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"region"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, store *Store) ([]Choice, error) { + c := store.Collected() + if c["region"] == "FIN-01" { + return []Choice{{Label: "H100", Value: "h100"}}, nil + } + return []Choice{{Label: "A100", Value: "a100"}}, nil + }, + Setter: func(v any) { gpu = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0), selectResult(0)}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if region != "FIN-01" { + t.Errorf("expected region 'FIN-01', got %q", region) + } + if gpu != "h100" { + t.Errorf("expected gpu 'h100', got %q", gpu) + } +} + +func TestEngine_SkipAlreadySet(t *testing.T) { + var hostname string + alreadySet := true + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "hostname", + Prompt: TextInputPrompt, + Required: true, + IsSet: func() bool { return alreadySet }, + Setter: func(v any) { hostname = v.(string) }, + }, + }, + } + + engine := newTestEngine(nil) // no prompting needed + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hostname != "" { + t.Error("hostname should not be set — step was skipped") + } +} + +func TestEngine_ShouldSkip(t *testing.T) { + var contract string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "category", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Spot", Value: "spot"}), + Setter: func(v any) {}, + }, + { + Name: "contract", + Prompt: SelectPrompt, + Required: false, + ShouldSkip: func(c map[string]any) bool { return c["category"] == "spot" }, + Loader: StaticChoices(Choice{Label: "Monthly", Value: "monthly"}), + Setter: func(v any) { contract = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0)}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contract != "" { + t.Error("contract should be empty — step was skipped for spot") + } +} + +func TestEngine_TextInput(t *testing.T) { + var hostname string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "hostname", + Prompt: TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { return "my-vm-001" }, + Setter: func(v any) { hostname = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{textResult("custom-host")}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hostname != "custom-host" { + t.Errorf("expected 'custom-host', got %q", hostname) + } +} + +func TestEngine_MultiSelect(t *testing.T) { + var keys []string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "ssh-keys", + Prompt: MultiSelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "key-a", Value: "id-a"}, + Choice{Label: "key-b", Value: "id-b"}, + Choice{Label: "key-c", Value: "id-c"}, + ), + Setter: func(v any) { keys = v.([]string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{multiSelectResult([]int{0, 2})}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(keys) != 2 || keys[0] != "id-a" || keys[1] != "id-c" { + t.Errorf("expected [id-a, id-c], got %v", keys) + } +} + +func TestEngine_Confirm(t *testing.T) { + var isSpot bool + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "spot", + Prompt: ConfirmPrompt, + Required: true, + Setter: func(v any) { isSpot = v.(bool) }, + }, + }, + } + + engine := newTestEngine([]promptResult{confirmResult(true)}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !isSpot { + t.Error("expected isSpot to be true") + } +} + +func TestEngine_Password(t *testing.T) { + var token string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "token", + Prompt: PasswordPrompt, + Required: true, + Setter: func(v any) { token = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{passwordResult("secret-123")}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "secret-123" { + t.Errorf("expected 'secret-123', got %q", token) + } +} + +func TestEngine_DefaultUsedForOptional(t *testing.T) { + var desc string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "description", + Prompt: TextInputPrompt, + Required: false, + Default: func(_ map[string]any) any { return "auto-generated" }, + Setter: func(v any) { desc = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{textResult("")}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if desc != "auto-generated" { + t.Errorf("expected 'auto-generated', got %q", desc) + } +} + +func TestEngine_BackNavigation_EmptyRequired(t *testing.T) { + var region, gpu string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "Finland", Value: "FIN-01"}, + Choice{Label: "Sweden", Value: "SWE-01"}, + ), + Setter: func(v any) { region = v.(string) }, + }, + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"region"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, store *Store) ([]Choice, error) { + c := store.Collected() + if c["region"] == "FIN-01" { + return []Choice{}, nil // empty — triggers auto-back + } + return []Choice{{Label: "A100", Value: "a100"}}, nil + }, + Setter: func(v any) { gpu = v.(string) }, + }, + }, + } + + // First: select FIN-01 (idx 0) → gpu empty → auto-back + // Second: select SWE-01 (idx 1) → gpu has A100 → select idx 0 + engine := newTestEngine([]promptResult{ + selectResult(0), // region: Finland + selectResult(1), // region: Sweden (after auto-back) + selectResult(0), // gpu: A100 + }) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if region != "SWE-01" { + t.Errorf("expected region 'SWE-01', got %q", region) + } + if gpu != "a100" { + t.Errorf("expected gpu 'a100', got %q", gpu) + } +} + +func TestEngine_EmptyRequired_AtFirstStep_ReturnsError(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{}, nil + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine(nil) + err := engine.Run(context.Background(), flow) + if err == nil { + t.Fatal("expected error when first step has no options") + } +} + +func TestEngine_ValidationError_RepromptsUntilValid(t *testing.T) { + var size string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "size", + Prompt: TextInputPrompt, + Required: true, + Validate: func(v any) error { + if v.(string) == "bad" { + return fmt.Errorf("invalid size") + } + return nil + }, + Setter: func(v any) { size = v.(string) }, + }, + }, + } + + // First: "bad" (fails validation, re-prompt), second: "100" (passes) + engine := newTestEngine([]promptResult{ + textResult("bad"), + textResult("100"), + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if size != "100" { + t.Errorf("expected '100', got %q", size) + } +} + +func TestEngine_CachesLoaderResults(t *testing.T) { + loadCount := 0 + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "static", + Prompt: SelectPrompt, + Required: true, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + loadCount++ + return []Choice{{Label: "A", Value: "a"}}, nil + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0)}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loadCount != 1 { + t.Errorf("expected loader called once, got %d", loadCount) + } +} + +func TestEngine_FullFlow_VMCreate(t *testing.T) { + var category, contract, compute, instType, location, image, hostname string + var sshKeys []string + + flow := &Flow{ + Name: "vm-create", + Steps: []Step{ + { + Name: "instance-category", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "On-Demand", Value: "on-demand"}, + Choice{Label: "Spot", Value: "spot"}, + ), + Setter: func(v any) { category = v.(string) }, + }, + { + Name: "contract", + Prompt: SelectPrompt, + Required: false, + Default: func(_ map[string]any) any { return "PAY_AS_YOU_GO" }, + ShouldSkip: func(c map[string]any) bool { return c["instance-category"] == "spot" }, + Loader: StaticChoices( + Choice{Label: "Pay as you go", Value: "PAY_AS_YOU_GO"}, + Choice{Label: "1 month", Value: "1_MONTH"}, + ), + Setter: func(v any) { contract = v.(string) }, + }, + { + Name: "compute-category", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "GPU", Value: "GPU"}, + Choice{Label: "CPU", Value: "CPU"}, + ), + Setter: func(v any) { compute = v.(string) }, + }, + { + Name: "instance-type", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"compute-category"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, store *Store) ([]Choice, error) { + c := store.Collected() + if c["compute-category"] == "GPU" { + return []Choice{ + {Label: "H100 80GB", Value: "1H100"}, + {Label: "A100 40GB", Value: "1A100"}, + }, nil + } + return []Choice{{Label: "32 vCPU", Value: "32CPU"}}, nil + }, + Setter: func(v any) { instType = v.(string) }, + }, + { + Name: "location", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"instance-type"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{{Label: "Finland (FIN-01)", Value: "FIN-01"}}, nil + }, + Setter: func(v any) { location = v.(string) }, + }, + { + Name: "image", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"instance-type"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{{Label: "Ubuntu 24.04", Value: "ubuntu-24.04"}}, nil + }, + Setter: func(v any) { image = v.(string) }, + }, + { + Name: "ssh-keys", + Prompt: MultiSelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "my-key", Value: "key-1"}, + Choice{Label: "work-key", Value: "key-2"}, + ), + Setter: func(v any) { sshKeys = v.([]string) }, + }, + { + Name: "hostname", + Prompt: TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { return "vm-001" }, + Setter: func(v any) { hostname = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // on-demand + selectResult(0), // pay as you go + selectResult(0), // GPU + selectResult(0), // H100 + selectResult(0), // FIN-01 + selectResult(0), // Ubuntu + multiSelectResult([]int{0, 1}), // both SSH keys + textResult("my-vm"), // hostname + }) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if category != "on-demand" { + t.Errorf("category: got %q", category) + } + if contract != "PAY_AS_YOU_GO" { + t.Errorf("contract: got %q", contract) + } + if compute != "GPU" { + t.Errorf("compute: got %q", compute) + } + if instType != "1H100" { + t.Errorf("instType: got %q", instType) + } + if location != "FIN-01" { + t.Errorf("location: got %q", location) + } + if image != "ubuntu-24.04" { + t.Errorf("image: got %q", image) + } + if len(sshKeys) != 2 { + t.Errorf("sshKeys: got %v", sshKeys) + } + if hostname != "my-vm" { + t.Errorf("hostname: got %q", hostname) + } +} + +func TestEngine_UserInitiatedBack(t *testing.T) { + var region, gpu string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "Finland", Value: "FIN-01"}, + Choice{Label: "Sweden", Value: "SWE-01"}, + ), + Setter: func(v any) { region = v.(string) }, + }, + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"region"}, + Loader: StaticChoices( + Choice{Label: "H100", Value: "h100"}, + Choice{Label: "A100", Value: "a100"}, + ), + Setter: func(v any) { gpu = v.(string) }, + }, + }, + } + + // Step 1: select Finland (idx 0) + // Step 2: select "← Back" (idx 2 = last, after h100 and a100) + // Step 1 again: select Sweden (idx 1) + // Step 2 again: select A100 (idx 1) + engine := newTestEngine([]promptResult{ + selectResult(0), // region: Finland + selectResult(2), // gpu: ← Back + selectResult(1), // region: Sweden + selectResult(1), // gpu: A100 + }) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if region != "SWE-01" { + t.Errorf("expected region 'SWE-01', got %q", region) + } + if gpu != "a100" { + t.Errorf("expected gpu 'a100', got %q", gpu) + } +} + +func TestEngine_EscBack(t *testing.T) { + // Esc (ActionBack) should navigate to the prior editable step. + var region, gpu string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "Finland", Value: "FIN-01"}, + Choice{Label: "Sweden", Value: "SWE-01"}, + ), + Setter: func(v any) { region = v.(string) }, + }, + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "H100", Value: "h100"}, + Choice{Label: "A100", Value: "a100"}, + ), + Setter: func(v any) { gpu = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // region: Finland + backResult(), // gpu: Esc → back to region + selectResult(1), // region: Sweden + selectResult(1), // gpu: A100 + }) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if region != "SWE-01" { + t.Errorf("expected region 'SWE-01', got %q", region) + } + if gpu != "a100" { + t.Errorf("expected gpu 'a100', got %q", gpu) + } +} + +func TestEngine_EscOnFirstStep_Cancels(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "first", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "A", Value: "a"}), + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine([]promptResult{backResult()}) + err := engine.Run(context.Background(), flow) + if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { + t.Fatalf("expected 'wizard cancelled', got %v", err) + } +} + +func TestEngine_CtrlC_Exits(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "first", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "A", Value: "a"}), + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine([]promptResult{exitResult()}) + err := engine.Run(context.Background(), flow) + if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { + t.Fatalf("expected 'wizard cancelled', got %v", err) + } +} + +func TestEngine_BackNotShownOnFirstStep(t *testing.T) { + var val string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "first", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "A", Value: "a"}, + Choice{Label: "B", Value: "b"}, + ), + Setter: func(v any) { val = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0)}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "a" { + t.Errorf("expected 'a', got %q", val) + } +} + +func TestEngine_RequiredTextInput_RepromptsOnEmpty(t *testing.T) { + var name string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "name", + Prompt: TextInputPrompt, + Required: true, + Setter: func(v any) { name = v.(string) }, + }, + }, + } + + // First: empty (re-prompt), second: valid + engine := newTestEngine([]promptResult{ + textResult(""), + textResult("my-service"), + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != "my-service" { + t.Errorf("expected 'my-service', got %q", name) + } +} + +func TestEngine_DependencyAwareAutoBack(t *testing.T) { + var region, name, gpu string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "Region A", Value: "a"}, + Choice{Label: "Region B", Value: "b"}, + ), + Setter: func(v any) { region = v.(string) }, + }, + { + Name: "name", + Prompt: TextInputPrompt, + Required: true, + Setter: func(v any) { name = v.(string) }, + }, + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"region"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, store *Store) ([]Choice, error) { + c := store.Collected() + if c["region"] == "a" { + return []Choice{}, nil // empty for region A + } + return []Choice{{Label: "H100", Value: "h100"}}, nil + }, + Setter: func(v any) { gpu = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // region: A + textResult("test"), // name + selectResult(1), // region: B (after dependency-aware auto-back) + textResult("test2"), // name again + selectResult(0), // gpu: H100 + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if region != "b" { + t.Errorf("expected region 'b', got %q", region) + } + if name != "test2" { + t.Errorf("expected name 'test2', got %q", name) + } + if gpu != "h100" { + t.Errorf("expected gpu 'h100', got %q", gpu) + } +} + +func TestEngine_NilSetter_NoPanic(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "temp", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "A", Value: "a"}), + // Setter intentionally nil — should not panic + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0)}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if engine.Collected()["temp"] != "a" { + t.Errorf("expected collected value 'a', got %v", engine.Collected()["temp"]) + } +} + +func TestEngine_ResetterCalledOnBack(t *testing.T) { + var category, contract string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "category", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "On-Demand", Value: "on-demand"}, + Choice{Label: "Spot", Value: "spot"}, + ), + Setter: func(v any) { category = v.(string) }, + Resetter: func() { category = "" }, + }, + { + Name: "contract", + Prompt: SelectPrompt, + Required: false, + Default: func(_ map[string]any) any { return "PAY_AS_YOU_GO" }, + ShouldSkip: func(c map[string]any) bool { return c["category"] == "spot" }, + Loader: StaticChoices(Choice{Label: "Monthly", Value: "monthly"}), + Setter: func(v any) { contract = v.(string) }, + Resetter: func() { contract = "" }, + }, + }, + } + + // First run: on-demand → monthly + engine := newTestEngine([]promptResult{ + selectResult(0), // category: on-demand + selectResult(0), // contract: monthly + }) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contract != "monthly" { + t.Errorf("expected 'monthly', got %q", contract) + } + + // Second run: on-demand, then go back from contract, pick spot → contract skipped & reset + contract = "stale-value" + engine2 := newTestEngine([]promptResult{ + selectResult(0), // category: on-demand + selectResult(1), // contract: ← Back + selectResult(1), // category: spot (contract will be skipped) + }) + err = engine2.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if category != "spot" { + t.Errorf("expected 'spot', got %q", category) + } + if _, exists := engine2.Collected()["contract"]; exists { + t.Error("contract should not be in collected — step was skipped") + } +} + +func TestEngine_SkipClearsStaleValue(t *testing.T) { + var contract string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "category", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "On-Demand", Value: "on-demand"}, + Choice{Label: "Spot", Value: "spot"}, + ), + Setter: func(v any) {}, + }, + { + Name: "contract", + Prompt: SelectPrompt, + Required: false, + ShouldSkip: func(c map[string]any) bool { return c["category"] == "spot" }, + Loader: StaticChoices(Choice{Label: "Monthly", Value: "monthly"}), + Setter: func(v any) { contract = v.(string) }, + Resetter: func() { contract = "" }, + }, + { + Name: "done", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "OK", Value: "ok"}), + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // category: on-demand + selectResult(0), // contract: monthly + selectResult(1), // done: ← Back + selectResult(1), // contract: ← Back + selectResult(1), // category: spot (contract skipped, resetter called) + selectResult(0), // done: OK + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if contract != "" { + t.Errorf("expected contract reset to empty, got %q", contract) + } +} + +func TestEngine_FixedDependency_ReturnsError(t *testing.T) { + fixedRegion := true + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + IsSet: func() bool { return fixedRegion }, + Loader: StaticChoices(Choice{Label: "A", Value: "a"}), + Setter: func(v any) {}, + }, + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"region"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{}, nil + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine(nil, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err == nil { + t.Fatal("expected error for fixed dependency with empty choices") + } +} + +func TestEngine_OptionalEmptyChoices_SkipsWithDefault(t *testing.T) { + var addon string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "addon", + Prompt: SelectPrompt, + Required: false, + Default: func(_ map[string]any) any { return "none" }, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{}, nil + }, + Setter: func(v any) { addon = v.(string) }, + }, + }, + } + + engine := newTestEngine(nil) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addon != "none" { + t.Errorf("expected 'none', got %q", addon) + } +} + +func TestEngine_IsSetPropagatesValueToCollected(t *testing.T) { + var gpu string + fixedRegion := "FIN-01" + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + IsSet: func() bool { return true }, + Value: func() any { return fixedRegion }, + Loader: StaticChoices(Choice{Label: "Finland", Value: "FIN-01"}), + Setter: func(v any) {}, + }, + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"region"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, store *Store) ([]Choice, error) { + c := store.Collected() + if c["region"] != "FIN-01" { + t.Errorf("expected region 'FIN-01' in collected, got %v", c["region"]) + } + return []Choice{{Label: "H100", Value: "h100"}}, nil + }, + Setter: func(v any) { gpu = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0)}) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gpu != "h100" { + t.Errorf("expected gpu 'h100', got %q", gpu) + } + if engine.Collected()["region"] != "FIN-01" { + t.Errorf("expected region in collected, got %v", engine.Collected()["region"]) + } +} + +func TestEngine_MixedFixedEditableDependencies(t *testing.T) { + var category, gpu string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + IsSet: func() bool { return true }, + Value: func() any { return "FIN-01" }, + Setter: func(v any) {}, + }, + { + Name: "category", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "GPU", Value: "GPU"}, + Choice{Label: "CPU", Value: "CPU"}, + ), + Setter: func(v any) { category = v.(string) }, + }, + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"region", "category"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, store *Store) ([]Choice, error) { + c := store.Collected() + if c["category"] == "GPU" { + return []Choice{}, nil + } + return []Choice{{Label: "32 vCPU", Value: "32cpu"}}, nil + }, + Setter: func(v any) { gpu = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // category: GPU + selectResult(1), // category: CPU (after auto-back) + selectResult(0), // gpu: 32cpu + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if category != "CPU" { + t.Errorf("expected category 'CPU', got %q", category) + } + if gpu != "32cpu" { + t.Errorf("expected gpu '32cpu', got %q", gpu) + } +} + +func TestEngine_OptionalEmptyNoDefault_Resets(t *testing.T) { + addon := "stale" + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "addon", + Prompt: SelectPrompt, + Required: false, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{}, nil + }, + Setter: func(v any) { addon = v.(string) }, + Resetter: func() { addon = "" }, + }, + }, + } + + engine := newTestEngine(nil) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if addon != "" { + t.Errorf("expected addon reset to empty, got %q", addon) + } +} + +func TestEngine_SkippedDepNotPickedByAutoBack(t *testing.T) { + var a, c string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "a", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "X", Value: "x"}, + Choice{Label: "Y", Value: "y"}, + ), + Setter: func(v any) { a = v.(string) }, + }, + { + Name: "b", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"a"}, + ShouldSkip: func(col map[string]any) bool { return col["a"] == "x" }, + Loader: StaticChoices(Choice{Label: "B1", Value: "b1"}), + Setter: func(v any) {}, + }, + { + Name: "c", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"b"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, store *Store) ([]Choice, error) { + col := store.Collected() + if col["b"] == nil { + return []Choice{}, nil + } + return []Choice{{Label: "C1", Value: "c1"}}, nil + }, + Setter: func(v any) { c = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // A: X + selectResult(1), // A: Y (after auto-back past skipped B) + selectResult(0), // B: B1 + selectResult(0), // C: C1 + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if a != "y" { + t.Errorf("expected a='y', got %q", a) + } + if c != "c1" { + t.Errorf("expected c='c1', got %q", c) + } +} + +func TestEngine_ShouldSkipOverridesIsSet(t *testing.T) { + var tls bool + tlsSet := true + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "env", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Dev", Value: "dev"}), + Setter: func(v any) {}, + }, + { + Name: "tls", + Prompt: ConfirmPrompt, + Required: true, + ShouldSkip: func(c map[string]any) bool { return c["env"] == "dev" }, + IsSet: func() bool { return tlsSet }, + Value: func() any { return true }, + Setter: func(v any) { tls = v.(bool) }, + Resetter: func() { tls = false }, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0)}, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tls { + t.Error("expected tls=false — ShouldSkip should override IsSet") + } + if _, exists := engine.Collected()["tls"]; exists { + t.Error("tls should not be in collected when skipped") + } +} + +func TestEngine_BackNotShownWhenAllPriorFixed(t *testing.T) { + var val string + fixedFirst := true + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "fixed", + Prompt: SelectPrompt, + IsSet: func() bool { return fixedFirst }, + Value: func() any { return "pre-set" }, + Loader: StaticChoices(Choice{Label: "X", Value: "x"}), + Setter: func(v any) {}, + }, + { + Name: "second", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "A", Value: "a"}), + Setter: func(v any) { val = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0)}, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "a" { + t.Errorf("expected 'a', got %q", val) + } +} + +func TestEngine_SkippedDepChainRewindsToController(t *testing.T) { + var env, svcName string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "env", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "Dev", Value: "dev"}, + Choice{Label: "Prod", Value: "prod"}, + ), + Setter: func(v any) { env = v.(string) }, + }, + { + Name: "svc-name", + Prompt: TextInputPrompt, + Required: true, + Setter: func(v any) { svcName = v.(string) }, + }, + { + Name: "tls", + Prompt: ConfirmPrompt, + Required: true, + ShouldSkip: func(c map[string]any) bool { return c["env"] == "dev" }, + Setter: func(v any) {}, + Resetter: func() {}, + }, + { + Name: "cert", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"tls"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, store *Store) ([]Choice, error) { + c := store.Collected() + if _, hasTLS := c["tls"]; !hasTLS { + return []Choice{}, nil + } + return []Choice{{Label: "wildcard.pem", Value: "wildcard"}}, nil + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // env: dev + textResult("myapp"), // svc-name + selectResult(1), // env: prod (after auto-back to earliest editable = env) + textResult("myapp2"), // svc-name again (was reset) + confirmResult(true), // tls: yes (not skipped for prod) + selectResult(0), // cert: wildcard + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if env != "prod" { + t.Errorf("expected env='prod', got %q", env) + } + if svcName != "myapp2" { + t.Errorf("expected svc-name='myapp2', got %q", svcName) + } +} + +func TestEngine_PresetValueValidated(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "replicas", + Prompt: TextInputPrompt, + IsSet: func() bool { return true }, + Value: func() any { return "bad" }, + Validate: func(v any) error { + if v.(string) == "bad" { + return fmt.Errorf("invalid replica count") + } + return nil + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine(nil, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err == nil { + t.Fatal("expected validation error for preset value") + } +} + +func TestEngine_RunClearsStateFromPreviousRun(t *testing.T) { + var val string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "item", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "A", Value: "a"}, Choice{Label: "B", Value: "b"}), + Setter: func(v any) { val = v.(string) }, + }, + }, + } + + // First run: select A + engine := newTestEngine([]promptResult{selectResult(0)}, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("run 1: %v", err) + } + if val != "a" { + t.Errorf("run 1: expected 'a', got %q", val) + } + + // Second run with same engine: select B + engine.resultOverride = testResultCh(selectResult(1)) + err = engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("run 2: %v", err) + } + if val != "b" { + t.Errorf("run 2: expected 'b', got %q", val) + } + if engine.Collected()["item"] != "b" { + t.Errorf("collected should be 'b', got %v", engine.Collected()["item"]) + } +} + +func TestEngine_SkippedDepsNoRewindTarget_ReturnsError(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "fixed", + Prompt: SelectPrompt, + IsSet: func() bool { return true }, + Value: func() any { return "x" }, + Setter: func(v any) {}, + }, + { + Name: "skipped", + Prompt: SelectPrompt, + ShouldSkip: func(c map[string]any) bool { return true }, + Loader: StaticChoices(Choice{Label: "S", Value: "s"}), + Setter: func(v any) {}, + }, + { + Name: "broken", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"skipped"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{}, nil + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine(nil, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err == nil { + t.Fatal("expected error when no rewind target exists") + } +} + +func TestEngine_BackSkipsAutoSkippedOptional(t *testing.T) { + var a, c string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "a", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "A1", Value: "a1"}, + Choice{Label: "A2", Value: "a2"}, + ), + Setter: func(v any) { a = v.(string) }, + }, + { + Name: "b", + Prompt: SelectPrompt, + Required: false, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{}, nil // always empty → auto-skipped + }, + Setter: func(v any) {}, + }, + { + Name: "c", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "C1", Value: "c1"}, + Choice{Label: "C2", Value: "c2"}, + ), + Setter: func(v any) { c = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // A: A1 + selectResult(2), // C: ← Back (skips past auto-skipped B to A) + selectResult(1), // A: A2 + selectResult(0), // C: C1 + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if a != "a2" { + t.Errorf("expected a='a2', got %q", a) + } + if c != "c1" { + t.Errorf("expected c='c1', got %q", c) + } +} + +func TestEngine_ConfirmDefaultHonored(t *testing.T) { + var tls bool + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "tls", + Prompt: ConfirmPrompt, + Required: true, + Default: func(_ map[string]any) any { return true }, + Setter: func(v any) { tls = v.(bool) }, + }, + }, + } + + engine := newTestEngine([]promptResult{confirmResult(true)}, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !tls { + t.Error("expected tls=true") + } +} + +func TestEngine_AutoSkippedDepNotRewindTarget(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "addon", + Prompt: SelectPrompt, + Required: false, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{}, nil + }, + Setter: func(v any) {}, + }, + { + Name: "addon-config", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"addon"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{}, nil + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine(nil, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err == nil { + t.Fatal("expected error, not infinite loop") + } +} + +func TestEngine_NameFallbackWhenDescriptionEmpty(t *testing.T) { + var val string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "A", Value: "a"}), + Setter: func(v any) { val = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0)}, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "a" { + t.Errorf("expected 'a', got %q", val) + } +} + +func TestEngine_SelectDefaultForwarded(t *testing.T) { + var val string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "size", + Prompt: SelectPrompt, + Required: true, + Default: func(_ map[string]any) any { return "medium" }, + Loader: StaticChoices( + Choice{Label: "Small", Value: "small"}, + Choice{Label: "Medium", Value: "medium"}, + Choice{Label: "Large", Value: "large"}, + ), + Setter: func(v any) { val = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(1)}, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "medium" { + t.Errorf("expected 'medium', got %q", val) + } +} + +func TestEngine_GoBackClearsDownstreamCollected(t *testing.T) { + var a, b, c string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "a", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "A1", Value: "a1"}, Choice{Label: "A2", Value: "a2"}), + Setter: func(v any) { a = v.(string) }, + }, + { + Name: "b", + Prompt: TextInputPrompt, + Required: true, + Setter: func(v any) { b = v.(string) }, + }, + { + Name: "c", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "C1", Value: "c1"}), + Setter: func(v any) { c = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // A: A1 + textResult("hello"), // B: hello + selectResult(1), // C: ← Back + textResult("world"), // B: world + selectResult(0), // C: C1 + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if a != "a1" { + t.Errorf("expected a='a1', got %q", a) + } + if b != "world" { + t.Errorf("expected b='world', got %q", b) + } + if c != "c1" { + t.Errorf("expected c='c1', got %q", c) + } +} + +func TestEngine_SkippedDepWithFixedController_ErrorsNotLoops(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "env", + Prompt: SelectPrompt, + IsSet: func() bool { return true }, + Value: func() any { return "dev" }, + Setter: func(v any) {}, + }, + { + Name: "svc-name", + Prompt: TextInputPrompt, + Required: true, + Setter: func(v any) {}, + }, + { + Name: "tls", + Prompt: ConfirmPrompt, + Required: true, + ShouldSkip: func(c map[string]any) bool { return c["env"] == "dev" }, + Setter: func(v any) {}, + }, + { + Name: "cert", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"tls"}, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return []Choice{}, nil + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine([]promptResult{ + textResult("svc1"), + textResult("svc2"), + textResult("svc3"), + textResult("svc4"), + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err == nil { + t.Fatal("expected error, not infinite loop") + } +} + +func TestEngine_IsEditable_FixedVsIsSet(t *testing.T) { + var region, env, gpu string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "Finland", Value: "FIN-01"}, + Choice{Label: "Sweden", Value: "SWE-01"}, + ), + Setter: func(v any) { region = v.(string) }, + }, + { + Name: "env", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Prod", Value: "prod"}), + Setter: func(v any) { env = v.(string) }, + IsSet: func() bool { return true }, + Value: func() any { return "prod" }, + }, + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices( + Choice{Label: "H100", Value: "h100"}, + Choice{Label: "A100", Value: "a100"}, + ), + Setter: func(v any) { gpu = v.(string) }, + }, + }, + } + + engine := newTestEngine([]promptResult{ + selectResult(0), // region: Finland + selectResult(2), // gpu: ← Back (skips fixed env, goes to region) + selectResult(1), // region: Sweden + selectResult(1), // gpu: A100 + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if region != "SWE-01" { + t.Errorf("expected region 'SWE-01', got %q", region) + } + if env != "prod" { + t.Errorf("expected env 'prod', got %q", env) + } + if gpu != "a100" { + t.Errorf("expected gpu 'a100', got %q", gpu) + } +} + +func TestEngine_LoaderError_Propagated(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return nil, fmt.Errorf("connection failed") + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine(nil, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "connection failed") { + t.Errorf("expected 'connection failed' in error, got %q", err) + } +} + +func TestEngine_LoaderReceivesStatusAndStore(t *testing.T) { + var gotStore bool + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "a", + Prompt: SelectPrompt, + Required: true, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, store *Store) ([]Choice, error) { + gotStore = store != nil + store.Set("loaded", true) + return []Choice{{Label: "X", Value: "x"}}, nil + }, + Setter: func(v any) {}, + }, + }, + } + + engine := newTestEngine([]promptResult{selectResult(0)}, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !gotStore { + t.Error("loader should receive non-nil store") + } + v, ok := engine.Store().Get("loaded") + if !ok || v != true { + t.Error("loader should be able to write to store") + } +} + +// TestEngine_MultiSelectMinErrorPlumbed verifies that Step.MinError reaches +// the multiselect model built by the engine, replacing the default +// validation message for a Required multi-select. +func TestEngine_MultiSelectMinErrorPlumbed(t *testing.T) { + engine := newTestEngine(nil) + step := Step{ + Name: "regions", + Prompt: MultiSelectPrompt, + Required: true, + MinError: func(min int) string { return fmt.Sprintf("choose %d+ region(s)", min) }, + } + choices := []Choice{{Label: "us", Value: "us"}, {Label: "eu", Value: "eu"}} + + model := engine.buildPromptModel(step, choices, false) + + // Press Enter with nothing selected: Required enforces min=1, so the + // confirm handler fires the (now custom) min-error message. + updated, _ := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + view := updated.(bubbletea.PromptModel).View().Content + + if !strings.Contains(view, "choose 1+ region(s)") { + t.Errorf("expected wizard MinError override in view, got %q", view) + } +} diff --git a/pkg/tui/wizard/integration_test.go b/pkg/tui/wizard/integration_test.go new file mode 100644 index 0000000..000309e --- /dev/null +++ b/pkg/tui/wizard/integration_test.go @@ -0,0 +1,251 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + "time" + + tuitesting "github.com/verda-cloud/verda-cli/pkg/tui/testing" +) + +// keySequence builds a byte sequence from VT100 escape codes. +func keySequence(keys ...string) io.Reader { + var buf bytes.Buffer + for _, k := range keys { + buf.WriteString(k) + } + return &buf +} + +const ( + keyEnter = "\r" + keyCtrlC = "\x03" + keyDown = "\x1b[B" +) + +func TestIntegration_SelectAndComplete(t *testing.T) { + var env string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "env", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Dev", Value: "dev"}, Choice{Label: "Prod", Value: "prod"}), + Setter: func(v any) { env = v.(string) }, + }, + }, + } + + // Enter selects first item + input := keySequence(keyEnter) + engine := NewEngine(tuitesting.New(), nil, + WithInput(input), + WithOutput(io.Discard), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := engine.Run(ctx, flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if env != "dev" { + t.Errorf("expected env 'dev', got %q", env) + } +} + +func TestIntegration_TwoStepFlow(t *testing.T) { + var env, region string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "env", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Dev", Value: "dev"}, Choice{Label: "Prod", Value: "prod"}), + Setter: func(v any) { env = v.(string) }, + }, + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "US", Value: "us"}, Choice{Label: "EU", Value: "eu"}), + Setter: func(v any) { region = v.(string) }, + }, + }, + } + + // Enter (select first), Enter (select first) + input := keySequence(keyEnter, keyEnter) + engine := NewEngine(tuitesting.New(), nil, + WithInput(input), + WithOutput(io.Discard), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := engine.Run(ctx, flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if env != "dev" { + t.Errorf("expected env 'dev', got %q", env) + } + if region != "us" { + t.Errorf("expected region 'us', got %q", region) + } +} + +func TestIntegration_CtrlC_Exits(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "env", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Dev", Value: "dev"}), + Setter: func(v any) {}, + }, + }, + } + + input := keySequence(keyCtrlC) + engine := NewEngine(tuitesting.New(), nil, + WithInput(input), + WithOutput(io.Discard), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := engine.Run(ctx, flow) + if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { + t.Fatalf("expected 'wizard cancelled', got %v", err) + } +} + +func TestIntegration_ArrowDownAndSelect(t *testing.T) { + var env string + + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "env", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Dev", Value: "dev"}, Choice{Label: "Prod", Value: "prod"}), + Setter: func(v any) { env = v.(string) }, + }, + }, + } + + // Down, Enter (select second item) + input := keySequence(keyDown, keyEnter) + engine := NewEngine(tuitesting.New(), nil, + WithInput(input), + WithOutput(io.Discard), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := engine.Run(ctx, flow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if env != "prod" { + t.Errorf("expected env 'prod', got %q", env) + } +} + +// TestIntegration_CtrlC_ConfirmExit_SingleY exercises the +// WithExitConfirmation() happy path: Ctrl+C shows "Exit wizard?", user types +// 'y', wizard exits cleanly. +func TestIntegration_CtrlC_ConfirmExit_SingleY(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "env", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Dev", Value: "dev"}), + Setter: func(v any) {}, + }, + }, + } + + input := keySequence(keyCtrlC, "y", keyEnter) + engine := NewEngine(tuitesting.New(), nil, + WithInput(input), + WithOutput(io.Discard), + WithExitConfirmation(), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := engine.Run(ctx, flow) + if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { + t.Fatalf("expected 'wizard cancelled', got %v", err) + } +} + +// TestIntegration_CtrlC_ConfirmExit_DoubleCtrlC verifies the +// "double-tap Ctrl+C force exits" behavior: hitting Ctrl+C twice must get +// the user out of the wizard even with WithExitConfirmation() enabled. +func TestIntegration_CtrlC_ConfirmExit_DoubleCtrlC(t *testing.T) { + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "env", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Dev", Value: "dev"}), + Setter: func(v any) {}, + }, + }, + } + + input := keySequence(keyCtrlC, keyCtrlC) + engine := NewEngine(tuitesting.New(), nil, + WithInput(input), + WithOutput(io.Discard), + WithExitConfirmation(), + ) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := engine.Run(ctx, flow) + if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { + t.Fatalf("expected 'wizard cancelled' on double Ctrl+C, got %v", err) + } +} diff --git a/pkg/tui/wizard/keybinding.go b/pkg/tui/wizard/keybinding.go new file mode 100644 index 0000000..d23c13d --- /dev/null +++ b/pkg/tui/wizard/keybinding.go @@ -0,0 +1,59 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import tea "charm.land/bubbletea/v2" + +// Action represents a wizard-level command triggered by a key binding. +type Action int + +const ( + ActionExit Action = iota // exit the wizard + ActionBack // go to previous step (reserved for future use) +) + +// KeyPattern matches a tea.KeyPressMsg. +type KeyPattern struct { + Code rune + Mod tea.KeyMod +} + +// KeyBinding maps a key pattern to a wizard-level action. +type KeyBinding struct { + Key KeyPattern + Action Action + Label string // displayed in hint bar +} + +// DefaultKeyBindings returns the default wizard key bindings. +// The Ctrl+C binding's Label is empty so the prompt's Hints() owns +// the "ctrl+c exit" display — the composite concatenates without +// dedup, and a label here would duplicate it. +func DefaultKeyBindings() []KeyBinding { + return []KeyBinding{ + {Key: KeyPattern{Code: 'c', Mod: tea.ModCtrl}, Action: ActionExit, Label: ""}, + } +} + +// MatchBinding checks if a key message matches any binding. +// Returns the action and true if matched, or zero and false if not. +func MatchBinding(bindings []KeyBinding, msg tea.KeyPressMsg) (Action, bool) { + for _, b := range bindings { + if msg.Code == b.Key.Code && msg.Mod == b.Key.Mod { + return b.Action, true + } + } + return 0, false +} diff --git a/pkg/tui/wizard/keybinding_test.go b/pkg/tui/wizard/keybinding_test.go new file mode 100644 index 0000000..245000c --- /dev/null +++ b/pkg/tui/wizard/keybinding_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestKeyBinding_MatchCtrlC(t *testing.T) { + bindings := DefaultKeyBindings() + msg := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} + + action, ok := MatchBinding(bindings, msg) + if !ok { + t.Fatal("expected Ctrl+C to match a binding") + } + if action != ActionExit { + t.Errorf("expected ActionExit, got %v", action) + } +} + +func TestKeyBinding_NoMatchForEnter(t *testing.T) { + bindings := DefaultKeyBindings() + msg := tea.KeyPressMsg{Code: tea.KeyEnter} + + _, ok := MatchBinding(bindings, msg) + if ok { + t.Fatal("Enter should not match any wizard binding") + } +} + +func TestKeyBinding_NoMatchForEsc(t *testing.T) { + bindings := DefaultKeyBindings() + msg := tea.KeyPressMsg{Code: tea.KeyEscape} + + _, ok := MatchBinding(bindings, msg) + if ok { + t.Fatal("Esc should not match any wizard binding — it goes to the prompt") + } +} + +func TestKeyBinding_CustomBinding(t *testing.T) { + bindings := []KeyBinding{ + {Key: KeyPattern{Code: 'q', Mod: tea.ModCtrl}, Action: ActionExit, Label: "ctrl+q exit"}, + } + msg := tea.KeyPressMsg{Code: 'q', Mod: tea.ModCtrl} + + action, ok := MatchBinding(bindings, msg) + if !ok { + t.Fatal("expected Ctrl+Q to match custom binding") + } + if action != ActionExit { + t.Errorf("expected ActionExit, got %v", action) + } +} diff --git a/pkg/tui/wizard/store.go b/pkg/tui/wizard/store.go new file mode 100644 index 0000000..fe84ce2 --- /dev/null +++ b/pkg/tui/wizard/store.go @@ -0,0 +1,89 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "maps" + "sync" +) + +// Store is the engine's shared data layer. Views read from it, +// the engine and loaders write to it. +type Store struct { + collected map[string]any + data map[string]any + mu sync.RWMutex + onChange func(key string, value any) // optional callback when Set is called +} + +// NewStore creates an empty store. +func NewStore() *Store { + return &Store{ + collected: make(map[string]any), + data: make(map[string]any), + } +} + +// Reset clears all collected and arbitrary data. +func (s *Store) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.collected = make(map[string]any) + s.data = make(map[string]any) +} + +// Collected returns a snapshot of the wizard step values. +func (s *Store) Collected() map[string]any { + s.mu.RLock() + defer s.mu.RUnlock() + snap := make(map[string]any, len(s.collected)) + maps.Copy(snap, s.collected) + return snap +} + +// SetCollected sets a wizard step value. +func (s *Store) SetCollected(key string, value any) { + s.mu.Lock() + defer s.mu.Unlock() + s.collected[key] = value +} + +// ClearCollected removes a wizard step value. +func (s *Store) ClearCollected(key string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.collected, key) +} + +// Get reads an arbitrary value from the store. +func (s *Store) Get(key string) (any, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + v, ok := s.data[key] + return v, ok +} + +// Set writes an arbitrary value to the store. +// If the engine has wired up a change callback (via the message bus), +// it broadcasts a StoreChangedMsg to all views. +func (s *Store) Set(key string, value any) { + s.mu.Lock() + s.data[key] = value + cb := s.onChange + s.mu.Unlock() + if cb != nil { + cb(key, value) + } +} diff --git a/pkg/tui/wizard/store_test.go b/pkg/tui/wizard/store_test.go new file mode 100644 index 0000000..833bd94 --- /dev/null +++ b/pkg/tui/wizard/store_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import "testing" + +func TestStore_Collected(t *testing.T) { + s := NewStore() + s.SetCollected("region", "FIN-01") + s.SetCollected("gpu", "h100") + + col := s.Collected() + if col["region"] != "FIN-01" { + t.Errorf("expected FIN-01, got %v", col["region"]) + } + if col["gpu"] != "h100" { + t.Errorf("expected h100, got %v", col["gpu"]) + } +} + +func TestStore_GetSet(t *testing.T) { + s := NewStore() + s.Set("cost", 3.50) + + v, ok := s.Get("cost") + if !ok { + t.Fatal("expected cost to be set") + } + if v != 3.50 { + t.Errorf("expected 3.50, got %v", v) + } + + _, ok = s.Get("missing") + if ok { + t.Error("expected missing key to return false") + } +} + +func TestStore_CollectedReturnsSnapshot(t *testing.T) { + s := NewStore() + s.SetCollected("a", "1") + + snap := s.Collected() + s.SetCollected("a", "2") + + if snap["a"] != "1" { + t.Error("Collected() should return a snapshot, not a live reference") + } +} + +func TestStore_Clear(t *testing.T) { + s := NewStore() + s.SetCollected("a", "1") + s.Set("cost", 5.0) + + s.ClearCollected("a") + + col := s.Collected() + if _, ok := col["a"]; ok { + t.Error("expected 'a' to be cleared") + } + + v, ok := s.Get("cost") + if !ok || v != 5.0 { + t.Error("store data should be unaffected by ClearCollected") + } +} + +func TestStore_Reset(t *testing.T) { + s := NewStore() + s.SetCollected("a", "1") + s.Set("cost", 5.0) + + s.Reset() + + col := s.Collected() + if len(col) != 0 { + t.Errorf("expected empty collected after reset, got %v", col) + } + _, ok := s.Get("cost") + if ok { + t.Error("expected empty data after reset") + } +} + +func TestStore_SetNotifiesCallback(t *testing.T) { + s := NewStore() + + var gotKey string + var gotValue any + s.onChange = func(key string, value any) { + gotKey = key + gotValue = value + } + + s.Set("cost", 3.50) + + if gotKey != "cost" { + t.Errorf("expected callback key 'cost', got %q", gotKey) + } + if gotValue != 3.50 { + t.Errorf("expected callback value 3.50, got %v", gotValue) + } +} diff --git a/pkg/tui/wizard/testing_helpers_test.go b/pkg/tui/wizard/testing_helpers_test.go new file mode 100644 index 0000000..df1b138 --- /dev/null +++ b/pkg/tui/wizard/testing_helpers_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +// testResultCh creates a buffered channel pre-filled with promptResults. +// The channel is closed after all results are written, so subsequent reads +// return the zero value (ActionExit) instead of blocking forever. +func testResultCh(results ...promptResult) chan promptResult { + ch := make(chan promptResult, len(results)) + for _, r := range results { + ch <- r + } + close(ch) + return ch +} + +// selectResult creates a promptResult for selecting an index. +func selectResult(idx int) promptResult { + return promptResult{value: idx, action: ActionNone} +} + +// textResult creates a promptResult for text input. +func textResult(text string) promptResult { + return promptResult{value: text, action: ActionNone} +} + +// confirmResult creates a promptResult for confirm. +func confirmResult(yes bool) promptResult { + return promptResult{value: yes, action: ActionNone} +} + +// multiSelectResult creates a promptResult for multi-select. +func multiSelectResult(indices []int) promptResult { + return promptResult{value: indices, action: ActionNone} +} + +// passwordResult creates a promptResult for password input. +func passwordResult(text string) promptResult { + return promptResult{value: text, action: ActionNone} +} + +// backResult creates a promptResult for back navigation (Esc). +func backResult() promptResult { + return promptResult{action: ActionBack} +} + +// exitResult creates a promptResult for exit (Ctrl+C). +func exitResult() promptResult { + return promptResult{action: ActionExit} +} diff --git a/pkg/tui/wizard/view.go b/pkg/tui/wizard/view.go new file mode 100644 index 0000000..30d3a11 --- /dev/null +++ b/pkg/tui/wizard/view.go @@ -0,0 +1,61 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import "reflect" + +// View is an actor that receives messages and renders output. +// Each view maintains its own state and renders independently. +type View interface { + // Update receives a message and returns: + // - render: the new display string for this view + // - publish: optional messages to broadcast to other views + Update(msg any) (render string, publish []any) + + // Subscribe returns the message types this view listens to. + // nil means receive all engine broadcasts only. + // Non-nil means receive only those types (plus engine broadcasts). + Subscribe() []reflect.Type +} + +// ViewDef defines a view in the layout. +type ViewDef struct { + ID string + View View +} + +// --- Engine broadcast messages --- + +// StepChangedMsg is broadcast when the engine moves to a new step. +type StepChangedMsg struct { + Current int + Total int + StepName string + PromptType PromptType + Collected map[string]any +} + +// CollectedChangedMsg is broadcast when a step completes and collected values change. +type CollectedChangedMsg struct { + Key string + Value any + Collected map[string]any +} + +// StoreChangedMsg is broadcast when a value in the store is set. +type StoreChangedMsg struct { + Key string + Value any +} diff --git a/pkg/tui/wizard/view_hintbar.go b/pkg/tui/wizard/view_hintbar.go new file mode 100644 index 0000000..a7c7cf8 --- /dev/null +++ b/pkg/tui/wizard/view_hintbar.go @@ -0,0 +1,108 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "image/color" + "reflect" + "strings" + + "charm.land/lipgloss/v2" +) + +// HintBarView renders contextual keyboard hints based on the current +// step's PromptType. It subscribes to StepChangedMsg and updates +// automatically as the wizard progresses. +type HintBarView struct { + promptType PromptType + style lipgloss.Style + sepStyle lipgloss.Style +} + +// HintBarOption configures the HintBarView. +type HintBarOption func(*HintBarView) + +// WithHintColor sets the foreground color for hint text and separators. +func WithHintColor(c color.Color) HintBarOption { + return func(v *HintBarView) { + v.style = lipgloss.NewStyle().Foreground(c) + v.sepStyle = lipgloss.NewStyle().Foreground(c) + } +} + +// WithHintStyle sets the full style for hint text and separators. +// Use this for no-color themes where Faint/Bold is needed instead of colors. +func WithHintStyle(s lipgloss.Style) HintBarOption { + return func(v *HintBarView) { + v.style = s + v.sepStyle = s + } +} + +// NewHintBarView creates a HintBarView. +func NewHintBarView(opts ...HintBarOption) *HintBarView { + v := &HintBarView{ + promptType: -1, // no step yet + style: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + sepStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + } + for _, opt := range opts { + opt(v) + } + return v +} + +// Update handles StepChangedMsg to update the hint based on PromptType. +func (v *HintBarView) Update(msg any) (string, []any) { + if m, ok := msg.(StepChangedMsg); ok { + v.promptType = m.PromptType + } + return v.render(), nil +} + +// Subscribe limits this view to StepChangedMsg only. +func (v *HintBarView) Subscribe() []reflect.Type { + return []reflect.Type{reflect.TypeOf(StepChangedMsg{})} +} + +func (v *HintBarView) render() string { + hints := v.hintsForPrompt(v.promptType) + if len(hints) == 0 { + return "" + } + sep := v.sepStyle.Render(" · ") + parts := make([]string, len(hints)) + for i, h := range hints { + parts[i] = v.style.Render(h) + } + return " " + strings.Join(parts, sep) +} + +func (v *HintBarView) hintsForPrompt(pt PromptType) []string { + switch pt { + case SelectPrompt: + return []string{"↑/↓ navigate", "type to filter", "enter select", "esc back"} + case MultiSelectPrompt: + return []string{"↑/↓ navigate", "space toggle", "enter confirm", "esc back"} + case TextInputPrompt: + return []string{"enter submit", "esc cancel"} + case ConfirmPrompt: + return []string{"y/n", "enter confirm"} + case PasswordPrompt: + return []string{"enter submit", "esc cancel"} + default: + return nil + } +} diff --git a/pkg/tui/wizard/view_progress.go b/pkg/tui/wizard/view_progress.go new file mode 100644 index 0000000..a2bb74e --- /dev/null +++ b/pkg/tui/wizard/view_progress.go @@ -0,0 +1,134 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "fmt" + "reflect" + + "charm.land/bubbles/v2/progress" + "charm.land/lipgloss/v2" +) + +// ProgressViewOption configures a ProgressView. +type ProgressViewOption func(*ProgressView) + +// WithProgressGradient sets the gradient colors for the progress bar. +// Defaults to the bubbles default gradient (#5A56E0 -> #EE6FF8). +func WithProgressGradient(colorA, colorB string) ProgressViewOption { + return func(r *ProgressView) { + r.colorA = colorA + r.colorB = colorB + } +} + +// WithProgressSolidFill uses a single color instead of a gradient. +func WithProgressSolidFill(color string) ProgressViewOption { + return func(r *ProgressView) { + r.solidFill = color + } +} + +// WithProgressWidth sets the bar width in characters (default: 40). +func WithProgressWidth(w int) ProgressViewOption { + return func(r *ProgressView) { + r.width = w + } +} + +// WithProgressPercent shows percentage text (e.g., "33%") instead of the +// default "Step X of Y" label. Follows the bubbletea animated progress example. +func WithProgressPercent() ProgressViewOption { + return func(r *ProgressView) { + r.showPercent = true + r.hideStepLabel = true + } +} + +// WithoutProgressStepLabel hides the "Step X of Y" label. +func WithoutProgressStepLabel() ProgressViewOption { + return func(r *ProgressView) { + r.hideStepLabel = true + } +} + +// ProgressView displays an animated-style step progress bar using +// the charmbracelet/bubbles progress component for gradient rendering. +// Responds to StepChangedMsg. +type ProgressView struct { + last string + colorA string + colorB string + solidFill string + width int + showPercent bool + hideStepLabel bool +} + +// NewProgressView creates a progress bar view. +func NewProgressView(opts ...ProgressViewOption) *ProgressView { + r := &ProgressView{ + width: 40, + } + for _, o := range opts { + o(r) + } + return r +} + +func (r *ProgressView) buildBar() progress.Model { + var opts []progress.Option + opts = append(opts, progress.WithWidth(r.width)) + if !r.showPercent { + opts = append(opts, progress.WithoutPercentage()) + } + if r.solidFill != "" { + opts = append(opts, progress.WithColors(lipgloss.Color(r.solidFill))) + } else if r.colorA != "" && r.colorB != "" { + opts = append(opts, progress.WithColors(lipgloss.Color(r.colorA), lipgloss.Color(r.colorB))) + } else { + opts = append(opts, progress.WithDefaultBlend()) + } + return progress.New(opts...) +} + +func (r *ProgressView) Update(msg any) (string, []any) { + sc, ok := msg.(StepChangedMsg) + if !ok { + return r.last, nil + } + + if sc.Total <= 1 { + r.last = "" + return r.last, nil + } + + pct := float64(sc.Current) / float64(sc.Total) + bar := r.buildBar() + + rendered := bar.ViewAs(pct) + if !r.hideStepLabel { + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + label := fmt.Sprintf(" Step %d of %d", sc.Current, sc.Total) + rendered += dimStyle.Render(label) + } + + r.last = fmt.Sprintf("\n%s\n", rendered) + return r.last, nil +} + +func (r *ProgressView) Subscribe() []reflect.Type { + return nil +} diff --git a/pkg/tui/wizard/view_test.go b/pkg/tui/wizard/view_test.go new file mode 100644 index 0000000..ece6f47 --- /dev/null +++ b/pkg/tui/wizard/view_test.go @@ -0,0 +1,179 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "reflect" + "strings" + "testing" +) + +func TestStepChangedMsg_Fields(t *testing.T) { + msg := StepChangedMsg{ + Current: 3, + Total: 12, + StepName: "instance-type", + Collected: map[string]any{"region": "FIN-01"}, + } + if msg.Current != 3 || msg.Total != 12 { + t.Error("StepChangedMsg fields not set correctly") + } + if msg.StepName != "instance-type" { + t.Errorf("expected StepName 'instance-type', got %q", msg.StepName) + } +} + +func TestViewDef_HasID(t *testing.T) { + def := ViewDef{ + ID: "progress", + } + if def.ID != "progress" { + t.Errorf("expected ID 'progress', got %q", def.ID) + } +} + +func TestSubscribeFilter(t *testing.T) { + subs := []reflect.Type{reflect.TypeFor[StepChangedMsg]()} + msgType := reflect.TypeFor[StepChangedMsg]() + + found := false + for _, s := range subs { + if s == msgType { + found = true + } + } + if !found { + t.Error("StepChangedMsg should match subscription") + } +} + +func TestProgressView_Render(t *testing.T) { + r := NewProgressView() + + out, pub := r.Update(StepChangedMsg{Current: 2, Total: 5, StepName: "gpu"}) + + if pub != nil { + t.Error("progress view should not publish messages") + } + if !strings.Contains(out, "Step 2 of 5") { + t.Errorf("expected 'Step 2 of 5' in output, got: %s", out) + } +} + +func TestProgressView_SingleStepHidden(t *testing.T) { + r := NewProgressView() + + out, _ := r.Update(StepChangedMsg{Current: 1, Total: 1, StepName: "only"}) + + if out != "" { + t.Errorf("single-step should produce empty output, got: %s", out) + } +} + +func TestProgressView_CustomGradient(t *testing.T) { + r := NewProgressView( + WithProgressGradient("#bd93f9", "#ff79c6"), + WithProgressWidth(20), + ) + + out, _ := r.Update(StepChangedMsg{Current: 3, Total: 6, StepName: "gpu"}) + + if !strings.Contains(out, "Step 3 of 6") { + t.Errorf("expected 'Step 3 of 6' in output, got: %s", out) + } +} + +func TestProgressView_SolidFill(t *testing.T) { + r := NewProgressView(WithProgressSolidFill("#50fa7b")) + + out, _ := r.Update(StepChangedMsg{Current: 1, Total: 3, StepName: "a"}) + + if !strings.Contains(out, "Step 1 of 3") { + t.Errorf("expected 'Step 1 of 3' in output, got: %s", out) + } +} + +func TestProgressView_PercentMode(t *testing.T) { + r := NewProgressView(WithProgressPercent()) + + out, _ := r.Update(StepChangedMsg{Current: 2, Total: 5, StepName: "gpu"}) + + if !strings.Contains(out, "40%") { + t.Errorf("expected '40%%' in output, got: %s", out) + } + if strings.Contains(out, "Step") { + t.Errorf("percent mode should not show step label, got: %s", out) + } +} + +func TestProgressView_NoLabel(t *testing.T) { + r := NewProgressView(WithoutProgressStepLabel()) + + out, _ := r.Update(StepChangedMsg{Current: 2, Total: 5, StepName: "gpu"}) + + if strings.Contains(out, "Step") { + t.Errorf("should not show step label, got: %s", out) + } + if strings.Contains(out, "%") { + t.Errorf("should not show percentage, got: %s", out) + } +} + +func TestProgressView_IgnoresOtherMessages(t *testing.T) { + r := NewProgressView() + + out, _ := r.Update(CollectedChangedMsg{Key: "x", Value: "y"}) + + if out != "" { + t.Errorf("should ignore non-StepChanged messages, got: %s", out) + } +} + +func TestCustomView_ReactsToCollectedChange(t *testing.T) { + cost := &costView{} + + bus := NewMessageBus() + bus.Register("cost", cost) + + bus.Broadcast(CollectedChangedMsg{ + Key: "instance-type", + Value: "1H100.80S", + Collected: map[string]any{ + "instance-type": "1H100.80S", + }, + }) + + renders := bus.RenderAll() + if !strings.Contains(renders[0], "$3.20/hr") { + t.Errorf("expected cost display, got: %s", renders[0]) + } +} + +type costView struct { + last string +} + +func (r *costView) Update(msg any) (string, []any) { + if m, ok := msg.(CollectedChangedMsg); ok { + if m.Collected["instance-type"] == "1H100.80S" { + r.last = " Estimated cost: $3.20/hr" + } + } + return r.last, nil +} + +func (r *costView) Subscribe() []reflect.Type { + return nil +} diff --git a/pkg/tui/wizard/wizard.go b/pkg/tui/wizard/wizard.go new file mode 100644 index 0000000..6eef340 --- /dev/null +++ b/pkg/tui/wizard/wizard.go @@ -0,0 +1,84 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "context" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// PromptType defines which TUI widget to use for a step. +type PromptType int + +const ( + SelectPrompt PromptType = iota // Single selection from a list. + MultiSelectPrompt // Multiple selections from a list. + TextInputPrompt // Free-form text input. + ConfirmPrompt // Yes/No confirmation. + PasswordPrompt // Masked text input. +) + +// Choice represents one selectable option in a list prompt. +type Choice struct { + Label string // Displayed to the user. + Value string // Actual value stored in collected map. + Description string // Optional extra info. +} + +// LoaderFunc fetches available choices for a step. +// It receives the Prompter for sub-prompts, Status for spinners/progress, +// and Store for reading/writing shared data. +// Use store.Collected() to access values from previously completed steps. +type LoaderFunc func(ctx context.Context, prompter tui.Prompter, status tui.Status, store *Store) ([]Choice, error) + +// Step defines one step in the wizard flow. +type Step struct { + Name string + Description string + Prompt PromptType + Required bool + Default func(collected map[string]any) any + ShouldSkip func(collected map[string]any) bool + Loader LoaderFunc + Validate func(value any) error + Setter func(value any) + Resetter func() // Called when step value is cleared (back/skip). Resets the bound variable. + IsSet func() bool // Returns true if value was provided via flag/config. + Value func() any // Returns the current value when IsSet is true. Propagates to collected map. + DependsOn []string + + // MinError customizes the "minimum selections" validation message for + // MultiSelectPrompt steps. The func receives the enforced minimum (1 + // for Required steps). nil uses the library default. Ignored by other + // prompt types. Useful to replace the grammatically awkward default + // ("at least 1 selections required") on required multi-selects. + MinError func(min int) string +} + +// Flow defines a complete wizard execution graph. +type Flow struct { + Name string + Steps []Step + Layout []ViewDef // optional; nil = default layout (progress bar) +} + +// StaticChoices returns a LoaderFunc that always returns the given choices. +// Use for steps with fixed options that don't require an API call. +func StaticChoices(choices ...Choice) LoaderFunc { + return func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + return choices, nil + } +} diff --git a/pkg/tui/wizard/wizard_test.go b/pkg/tui/wizard/wizard_test.go new file mode 100644 index 0000000..c435ea7 --- /dev/null +++ b/pkg/tui/wizard/wizard_test.go @@ -0,0 +1,94 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "context" + "testing" +) + +func TestPromptType_Constants(t *testing.T) { + types := []PromptType{ + SelectPrompt, + MultiSelectPrompt, + TextInputPrompt, + ConfirmPrompt, + PasswordPrompt, + } + seen := make(map[PromptType]bool) + for _, pt := range types { + if seen[pt] { + t.Errorf("duplicate PromptType value: %d", pt) + } + seen[pt] = true + } +} + +func TestChoice_Fields(t *testing.T) { + c := Choice{ + Label: "H100 80GB - $3.20/hr", + Value: "1H100.80S.30V", + Description: "8x NVIDIA H100", + } + if c.Label == "" || c.Value == "" { + t.Error("Choice fields should be populated") + } +} + +func TestStep_Fields(t *testing.T) { + s := Step{ + Name: "gpu", + Description: "Select GPU type", + Prompt: SelectPrompt, + Required: true, + DependsOn: []string{"location"}, + } + if s.Name != "gpu" { + t.Errorf("expected name 'gpu', got %q", s.Name) + } + if len(s.DependsOn) != 1 || s.DependsOn[0] != "location" { + t.Error("DependsOn should contain 'location'") + } +} + +func TestFlow_Fields(t *testing.T) { + f := Flow{ + Name: "vm-create", + Steps: []Step{{Name: "a"}, {Name: "b"}}, + } + if f.Name != "vm-create" { + t.Errorf("expected flow name 'vm-create', got %q", f.Name) + } + if len(f.Steps) != 2 { + t.Errorf("expected 2 steps, got %d", len(f.Steps)) + } +} + +func TestStaticChoices(t *testing.T) { + loader := StaticChoices( + Choice{Label: "A", Value: "a"}, + Choice{Label: "B", Value: "b"}, + ) + choices, err := loader(context.Background(), nil, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(choices) != 2 { + t.Fatalf("expected 2 choices, got %d", len(choices)) + } + if choices[0].Value != "a" || choices[1].Value != "b" { + t.Error("unexpected choice values") + } +} diff --git a/pkg/version/flag.go b/pkg/version/flag.go new file mode 100644 index 0000000..041bbd3 --- /dev/null +++ b/pkg/version/flag.go @@ -0,0 +1,91 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "fmt" + "os" + "strconv" + + "github.com/spf13/pflag" +) + +type versionValue int + +const ( + versionNotSet versionValue = iota + versionEnabled + versionRaw +) + +const versionFlagName = "version" + +var versionFlag = versionNotSet + +// Implements pflag.Value so --version accepts "true", "false", "raw". +func (v *versionValue) Set(s string) error { + if s == "raw" { + *v = versionRaw + return nil + } + b, err := strconv.ParseBool(s) + if err != nil { + return fmt.Errorf("invalid value %q for --%s: must be true, false, or raw", s, versionFlagName) + } + if b { + *v = versionEnabled + } else { + *v = versionNotSet + } + return nil +} + +func (v *versionValue) String() string { + switch *v { + case versionRaw: + return "raw" + case versionEnabled: + return "true" + default: + return "false" + } +} + +func (v *versionValue) Type() string { return "version" } + +func init() { + pflag.CommandLine.Var(&versionFlag, versionFlagName, `Print version information and quit. +Accepts "true", "false", or "raw" for full details.`) + pflag.CommandLine.Lookup(versionFlagName).NoOptDefVal = "true" +} + +// AddFlags adds the --version flag to the given FlagSet. +func AddFlags(fs *pflag.FlagSet) { + if f := pflag.CommandLine.Lookup(versionFlagName); f != nil { + fs.AddFlag(f) + } +} + +// PrintAndExitIfRequested prints version info and exits if --version was set. +func PrintAndExitIfRequested() { + switch versionFlag { + case versionRaw: + fmt.Println(Get().Text()) + os.Exit(0) + case versionEnabled: + fmt.Println(Get().String()) + os.Exit(0) + } +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..592b491 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,138 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package version provides build-time version information for binaries. +// +// Set the build variables via -ldflags: +// +// go build -ldflags "-X github.com/verda-cloud/verda-cli/pkg/version.gitVersion=v1.0.0 \ +// -X github.com/verda-cloud/verda-cli/pkg/version.gitCommit=$(git rev-parse HEAD) \ +// -X github.com/verda-cloud/verda-cli/pkg/version.gitTreeState=clean \ +// -X github.com/verda-cloud/verda-cli/pkg/version.buildDate=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" +package version + +import ( + "encoding/json" + "fmt" + "runtime" + "runtime/debug" +) + +const ( + unknownValue = "unknown" + trueValue = "true" +) + +// Build-time variables set via -ldflags. +var ( + gitVersion = "v0.0.0-dev" + gitCommit = unknownValue + gitTreeState = unknownValue + buildDate = unknownValue +) + +// Info holds the version information for a binary. +type Info struct { + GitVersion string `json:"gitVersion"` + GitCommit string `json:"gitCommit"` + GitTreeState string `json:"gitTreeState"` + BuildDate string `json:"buildDate"` + GoVersion string `json:"goVersion"` + Compiler string `json:"compiler"` + Platform string `json:"platform"` +} + +// Get returns the version information populated from build-time variables +// and runtime values. +func Get() Info { + return Info{ + GitVersion: gitVersion, + GitCommit: gitCommit, + GitTreeState: gitTreeState, + BuildDate: buildDate, + GoVersion: runtime.Version(), + Compiler: runtime.Compiler, + Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + } +} + +// GetFromDebugInfo returns version information extracted from Go's embedded +// debug build info. Useful when ldflags are not set (e.g. `go install`). +func GetFromDebugInfo(modulePath string) Info { + info := Get() + + bi, ok := debug.ReadBuildInfo() + if !ok { + return info + } + + if info.GitVersion == "v0.0.0-dev" { + for _, dep := range bi.Deps { + if dep.Path == modulePath { + info.GitVersion = dep.Version + break + } + } + if info.GitVersion == "v0.0.0-dev" && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + info.GitVersion = bi.Main.Version + } + } + + for _, setting := range bi.Settings { + switch setting.Key { + case "vcs.revision": + if info.GitCommit == unknownValue { + info.GitCommit = setting.Value + } + case "vcs.modified": + if info.GitTreeState == unknownValue { + if setting.Value == trueValue { + info.GitTreeState = "dirty" + } else { + info.GitTreeState = "clean" + } + } + case "vcs.time": + if info.BuildDate == unknownValue { + info.BuildDate = setting.Value + } + } + } + + return info +} + +// String returns the git version string. +func (i Info) String() string { + return i.GitVersion +} + +// ToJSON returns the version info as a JSON string. +func (i Info) ToJSON() string { + b, _ := json.Marshal(i) + return string(b) +} + +// Text returns a human-readable multi-line version summary. +func (i Info) Text() string { + return fmt.Sprintf(`gitVersion: %s +gitCommit: %s +gitTreeState: %s +buildDate: %s +goVersion: %s +compiler: %s +platform: %s`, + i.GitVersion, i.GitCommit, i.GitTreeState, + i.BuildDate, i.GoVersion, i.Compiler, i.Platform) +} diff --git a/pkg/version/version_test.go b/pkg/version/version_test.go new file mode 100644 index 0000000..689573d --- /dev/null +++ b/pkg/version/version_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package version + +import ( + "encoding/json" + "runtime" + "strings" + "testing" +) + +func TestGet_PopulatesRuntimeFields(t *testing.T) { + info := Get() + if info.GoVersion != runtime.Version() { + t.Errorf("expected GoVersion %q, got %q", runtime.Version(), info.GoVersion) + } + if info.Compiler != runtime.Compiler { + t.Errorf("expected Compiler %q, got %q", runtime.Compiler, info.Compiler) + } + if !strings.Contains(info.Platform, "/") { + t.Errorf("expected Platform to contain '/', got %q", info.Platform) + } +} + +func TestGet_DefaultBuildVars(t *testing.T) { + info := Get() + if info.GitVersion == "" { + t.Error("GitVersion should not be empty") + } +} + +func TestString(t *testing.T) { + info := Get() + if info.String() != info.GitVersion { + t.Errorf("String() should return GitVersion, got %q", info.String()) + } +} + +func TestToJSON_ValidJSON(t *testing.T) { + info := Get() + j := info.ToJSON() + var parsed map[string]string + if err := json.Unmarshal([]byte(j), &parsed); err != nil { + t.Fatalf("ToJSON() produced invalid JSON: %v", err) + } + if parsed["goVersion"] != runtime.Version() { + t.Errorf("JSON goVersion = %q, want %q", parsed["goVersion"], runtime.Version()) + } +} + +func TestText_ContainsAllFields(t *testing.T) { + info := Get() + text := info.Text() + for _, field := range []string{"gitVersion:", "gitCommit:", "gitTreeState:", "buildDate:", "goVersion:", "compiler:", "platform:"} { + if !strings.Contains(text, field) { + t.Errorf("Text() missing field %q", field) + } + } +} + +func TestVersionValue_Set(t *testing.T) { + tests := []struct { + input string + expected versionValue + wantErr bool + }{ + {"true", versionEnabled, false}, + {"false", versionNotSet, false}, + {"raw", versionRaw, false}, + {"invalid", 0, true}, + } + + for _, tc := range tests { + var v versionValue + err := v.Set(tc.input) + if (err != nil) != tc.wantErr { + t.Errorf("Set(%q): unexpected error state: %v", tc.input, err) + continue + } + if err == nil && v != tc.expected { + t.Errorf("Set(%q) = %v, want %v", tc.input, v, tc.expected) + } + } +} + +func TestVersionValue_String(t *testing.T) { + tests := []struct { + val versionValue + want string + }{ + {versionNotSet, "false"}, + {versionEnabled, "true"}, + {versionRaw, "raw"}, + } + for _, tc := range tests { + if got := tc.val.String(); got != tc.want { + t.Errorf("(%d).String() = %q, want %q", tc.val, got, tc.want) + } + } +} + +func TestVersionValue_Type(t *testing.T) { + var v versionValue + if v.Type() != "version" { + t.Errorf("Type() = %q, want 'version'", v.Type()) + } +}