diff --git a/README.md b/README.md index 1121823..053252e 100644 --- a/README.md +++ b/README.md @@ -41,39 +41,41 @@ untouched. ``` az network public-ip create Create a public IP address. -─────────────────────────────────────────────────────────────────────────────── -● --name pip-nat ○ --acquire-policy-token — -● --resource-group myResourceGroup ○ --change-reference — -● --allocation-method Static ○ --debug -○ --ddos-protection-mode — ○ --help -○ --ddos-protection-plan — ○ --only-show-errors -○ --dns-name — ● --output tsv -○ --dns-name-scope — ○ --query — -○ --edge-zone — ○ --subscription — -● --idle-timeout 4 ○ --verbose -○ --ip-address — -○ --ip-tags — -○ --location — -○ --public-ip-prefix — -○ --reverse-fqdn — -● --sku StandardV2 -○ --tags — -○ --tier — -● --version IPv4 -○ --zone — - -─────────────────────────────────────────────────────────────────────────────── +────────────────────────────────────────────────────────────────────────────────────────────────────────── +● --name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ $PIP → pip ○ --acquire-policy-token․․․․․․․․․․․․․․․․․․․․․․․ — +● --resource-group․․․․․․․․․ $RG → myResourceGroup ○ --change-reference․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --allocation-method․․․․․․․․․․․․․․․․․․․․․ Static ○ --debug +○ --ddos-protection-mode․․․․․․․․․․․․․․․․․․․․․․․ ▼ ○ --help +○ --ddos-protection-plan․․․․․․․․․․․․․․․․․․․․․․․ — ○ --only-show-errors +○ --dns-name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — ● --output․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ tsv +○ --dns-name-scope․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ ▼ ○ --query․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +○ --edge-zone․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — ○ --subscription․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --idle-timeout․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ 15 ○ --verbose +○ --ip-address․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +○ --ip-tags․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --location․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ $LOC +○ --public-ip-prefix․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +○ --reverse-fqdn․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --sku․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ StandardV2 +○ --tags․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --tier․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ Regional +● --version․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ IPv4 +● --zone․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — + +────────────────────────────────────────────────────────────────────────────────────────────────────────── az network public-ip create \ - --name pip-nat \ - --resource-group myResourceGroup \ + --name $PIP \ + --resource-group '$RG' \ --allocation-method Static \ - --idle-timeout 4 \ + --idle-timeout 15 \ + --location '$LOC' \ --sku StandardV2 \ + --tier Regional \ --version IPv4 \ --output tsv - Done Cancel Press F1 for help -─────────────────────────────────────────────────────────────────────────────── + Done Cancel Press F1 for help +────────────────────────────────────────────────────────────────────────────────────────────────────────── ``` ## What it is @@ -117,65 +119,119 @@ So the loop becomes: type, run, read error, fix, run again. Sometimes four or fi - Show every parameter of a command in one place, with required ones marked - Filter the parameter list as you type — names and help text are searched live, so a 100-parameter command like `az vm create` collapses to one row when you know what you're after ``` -○ --dns-name — -○ --dns-name-scope — +az network public-ip create +Create a public IP address. +────────────────────────────────────────────────────────────────────────────────────────────────────────── +○ --dns-name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +○ --dns-name-scope․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ ▼ +────────────────────────────────────────────────────────────────────────────────────────────────────────── / dns█ +────────────────────────────────────────────────────────────────────────────────────────────────────────── +az network public-ip create \ + --name $PIP \ + --resource-group '$RG' \ + --allocation-method Static \ + --idle-timeout 15 \ + --location '$LOC' \ + --sku StandardV2 \ + --tier Regional \ + --version IPv4 \ + --output tsv + + Done Cancel Press F1 for help +────────────────────────────────────────────────────────────────────────────────────────────────────────── ``` - Turn fixed value sets into pickable lists, so misspellings stop happening ``` -● --name pip-nat ○ --acquire-policy-token — -● --resource-group myResourceGroup ○ --change-reference — -● --allocation-method Static ○ --debug -○ --ddos-protection-mode — ○ --help -○ --ddos-protection-plan — ○ --only-show-errors -○ --dns-name — ● --output json -○ --dns-name-scope — ┌──────────────────────┐ — -○ --edge-zone — │▶ json │ — -● --idle-timeout 4 │ jsonc │ -○ --ip-address — │ none │ -○ --ip-tags — │ table │ -○ --location — │ tsv │ -○ --public-ip-prefix — │ yaml │ -○ --reverse-fqdn — │ yamlc │ -● --sku StandardV2 └──────────────────────┘ -○ --tags — -○ --tier — -● --version IPv4 -○ --zone — +az network public-ip create +Create a public IP address. +────────────────────────────────────────────────────────────────────────────────────────────────────────── +● --name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ $PIP → pip ○ --acquire-policy-token․․․․․․․․․․․․․․․․․․․․․․․ — +● --resource-group․․․․․․․․․ $RG → myResourceGroup ○ --change-reference․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --allocation-method․․․․․․․․․․․․․․․․․․․․․ Static ○ --debug +○ --ddos-protection-mode․․․․․․․․․․․․․․․․․․․․․․․ ▼ ○ --help +○ --ddos-protection-plan․․․․․․․․․․․․․․․․․․․․․․․ — ○ --only-show-errors +○ --dns-name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — ● --output․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ tsv +○ --dns-name-scope․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ ▼ ○ --query․․․․․․․․․․․․․․․․┌──────────────────────┐ +○ --edge-zone․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — ○ --subscription․․․․․․․․․│ json │ +● --idle-timeout․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ 15 ○ --verbose │ jsonc │ +○ --ip-address․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — │ none │ +○ --ip-tags․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — │ table │ +● --location․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ $LOC │▶ tsv │ +○ --public-ip-prefix․․․․․․․․․․․․․․․․․․․․․․․․․․․ — │ yaml │ +○ --reverse-fqdn․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — │ yamlc │ +● --sku․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ StandardV2 └──────────────────────┘ +○ --tags․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --tier․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ Regional +● --version․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ IPv4 +● --zone․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — + +────────────────────────────────────────────────────────────────────────────────────────────────────────── +az network public-ip create \ + --name $PIP \ + --resource-group '$RG' \ + --allocation-method Static \ + --idle-timeout 15 \ + --location '$LOC' \ + --sku StandardV2 \ + --tier Regional \ + --version IPv4 \ + --output tsv + + Done Cancel Press F1 for help +────────────────────────────────────────────────────────────────────────────────────────────────────────── ``` - Pick up a command you already started typing and let you finish it in the form - Fill fields with shell variables you already have defined, and remember which variable you used for which parameter - Warn you before you run a command that references a variable your shell doesn't actually have - Open a filtered variable picker from any field (`Ctrl-G`) to insert `$VAR` from the current shell session without scrolling through your whole env ``` -● --name pip-nat ○ --acquire-policy-token — -● --resource-group █ ○ --change-reference — -┌────────────────────────────────────────────────────────────────────────────────────────┐ -│ filter: my█ │ -│▶ myResourceGroup │ -│ my_git_format │ -│ │ -│ │ -│ │ -└────────────────────────────────────────────────────────────────────────────────────────┘ -● --allocation-method Static ○ --debug -○ --ddos-protection-mode — ○ --help -○ --ddos-protection-plan — ○ --only-show-errors -○ --dns-name — ● --output json -○ --dns-name-scope — ○ --query — -○ --edge-zone — ○ --subscription — -● --idle-timeout 4 ○ --verbose -○ --ip-address — -○ --ip-tags — -○ --location — -○ --public-ip-prefix — -○ --reverse-fqdn — -● --sku StandardV2 -○ --tags — -○ --tier — -● --version IPv4 -○ --zone — +az network public-ip create +Create a public IP address. +────────────────────────────────────────────────────────────────────────────────────────────────────────── +● --name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ $PIP → pip ○ --acquire-policy-token․․․․․․․․․․․․․․․․․․․․․․․ — +● --resource-group․․․․․․․․․ $RG → myResourceGroup ○ --change-reference․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +┌────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ filter: rg█ │ +│▶ RG │ +│ │ +│ │ +│ │ +│ │ +└────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +● --allocation-method․․․․․․․․․․․․․․․․․․․․․ Static ○ --debug +○ --ddos-protection-mode․․․․․․․․․․․․․․․․․․․․․․․ ▼ ○ --help +○ --ddos-protection-plan․․․․․․․․․․․․․․․․․․․․․․․ — ○ --only-show-errors +○ --dns-name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — ● --output․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ tsv +○ --dns-name-scope․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ ▼ ○ --query․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +○ --edge-zone․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — ○ --subscription․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --idle-timeout․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ 15 ○ --verbose +○ --ip-address․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +○ --ip-tags․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --location․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ $LOC +○ --public-ip-prefix․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +○ --reverse-fqdn․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --sku․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ StandardV2 +○ --tags․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — +● --tier․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ Regional +● --version․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ IPv4 +● --zone․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ — + +────────────────────────────────────────────────────────────────────────────────────────────────────────── +az network public-ip create \ + --name $PIP \ + --resource-group '$RG' \ + --allocation-method Static \ + --idle-timeout 15 \ + --location '$LOC' \ + --sku StandardV2 \ + --tier Regional \ + --version IPv4 \ + --output tsv + + Done Cancel Press F1 for help +────────────────────────────────────────────────────────────────────────────────────────────────────────── ``` - Show live values from your Azure subscription where it makes sense — resource groups, locations, existing resources - Save named presets, so "a storage account like the one in project X" is one keystroke @@ -203,7 +259,6 @@ Esc close popup; from list, cancel and save draft / filter by parameter name and help text Tab Shift-Tab cycle list → Done → Cancel → list g open a popup to set a shell variable (`name=value`, or just `name` to re-declare the current session value) — the widget evals the line in your shell after the form closes, so the var persists until you unset it; the var also appears in the Ctrl+G picker for the same session -G expand the Global Arguments section (the old `g` binding, shifted) a show all collapsed parameters v cycle value visibility for required params (see below) Ctrl-G select $VAR from the buffer list and insert at the cursor @@ -228,7 +283,20 @@ state 0 $RG (just the var reference) state 1 $RG → myResourceGroup (default; reference and resolved value) state 2 myResourceGroup (just the resolved value) ``` +``` +● --name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ $PIP +● --resource-group․․․․․․․․․․․․․․․․․․․․․․․․․․․ $RG +``` +``` +● --name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ $PIP → pip +● --resource-group․․․․․․․․․ $RG → myResourceGroup +``` + +``` +● --name․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․․ pip +● --resource-group․․․․․․․․․․․․․․․ myResourceGroup +``` After state 2, the next press wraps back to state 1, then 0, then 1, and so on. Required fields whose reference does **not** resolve in the current shell (e.g. you forgot to export `$RG`) stay red and diff --git a/internal/ui/globals_visible_test.go b/internal/ui/globals_visible_test.go new file mode 100644 index 0000000..e3dc802 --- /dev/null +++ b/internal/ui/globals_visible_test.go @@ -0,0 +1,84 @@ +package ui_test + +import ( + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/someson/azform/internal/metadata" + "github.com/someson/azform/internal/ui" +) + +// globalsFixture builds a form shaped like a real `az` command: a couple +// of required flags, a batch of optional ones, and the seven Global +// Arguments every az command carries. +func globalsFixture(t *testing.T, w, h int) ui.Form { + t.Helper() + ps := []metadata.Parameter{ + {Name: "--name", Required: true, TakesValue: true}, + {Name: "--resource-group", Required: true, TakesValue: true}, + } + for _, n := range []string{ + "--allocation-method", "--ddos-protection-mode", "--dns-name", + "--edge-zone", "--idle-timeout", "--ip-address", "--sku", + "--tags", "--tier", "--zone", + } { + ps = append(ps, metadata.Parameter{Name: n, TakesValue: true}) + } + for _, n := range []string{"--debug", "--help", "--only-show-errors", "--verbose"} { + ps = append(ps, metadata.Parameter{Name: n, Global: true, Group: "Global Arguments"}) + } + for _, n := range []string{"--output", "--query", "--subscription"} { + ps = append(ps, metadata.Parameter{Name: n, Global: true, TakesValue: true, Group: "Global Arguments"}) + } + f := ui.NewForm("network public-ip create", "/tmp/out.txt", t.TempDir(), "test", nil) + m, _ := f.Update(ui.MetadataLoadedMsg{Params: ps, Summary: "Create a public IP address."}) + f = m.(ui.Form) + m, _ = f.Update(tea.WindowSizeMsg{Width: w, Height: h}) + return m.(ui.Form) +} + +// TestGlobalsAlwaysInLayout pins the behaviour that replaced the G toggle: +// every Global Argument is part of the layout at every terminal size, with +// no hidden state and no key needed to reveal it. On a short terminal they +// may sit below the fold — that is ordinary scrolling, not hiding, so the +// assertion is on the layout rather than on the rendered window. +// +// The toggle this replaced was a no-op in every configuration measured: on +// a tall terminal an internal "they fit, show them anyway" rule overrode +// it, and on a short or narrow one the section stayed collapsed and G could +// not reveal it. The single-column path even printed "press G to show N +// global argument(s)", advertising a key that did nothing. +func TestGlobalsAlwaysInLayout(t *testing.T) { + t.Parallel() + const wantGlobals = 7 + sizes := []struct{ w, h int }{{140, 40}, {106, 30}, {106, 12}, {106, 5}, {80, 30}, {80, 10}} + for _, s := range sizes { + t.Run(fmt.Sprintf("%dx%d", s.w, s.h), func(t *testing.T) { + t.Parallel() + f := globalsFixture(t, s.w, s.h) + _, globalsCol, cols := f.GridLayout() + if cols >= 2 && len(globalsCol) != wantGlobals { + t.Errorf("%dx%d: globalsCol has %d entries, want %d — globals must never be withheld from the layout", + s.w, s.h, len(globalsCol), wantGlobals) + } + if v := f.View(); strings.Contains(v, "press G") { + t.Errorf("%dx%d: view still advertises the removed G toggle", s.w, s.h) + } + }) + } +} + +// TestGlobalsRenderWhenRoom is the visible counterpart: on a terminal with +// room for them, globals appear without any keypress. +func TestGlobalsRenderWhenRoom(t *testing.T) { + t.Parallel() + v := globalsFixture(t, 140, 40).View() + for _, want := range []string{"--verbose", "--output", "--subscription"} { + if !strings.Contains(v, want) { + t.Errorf("global %s not rendered on a roomy terminal; view:\n%s", want, v) + } + } +} diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index cf811f8..9a52bfb 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -261,17 +261,11 @@ func (m Form) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // pendingExports; main.go flushes the lines to --env-out on // Done, and the widget evals them in your interactive zsh // after azform exits so the var lives in the shell until - // you unset it. The old "toggle Global Arguments" behaviour - // moved to G (shift+g) — see the next case. + // you unset it. m.setVarInput.SetValue("") m.clearSetVarHint() m.mode = FormModeSetVar return m, m.setVarInput.Focus() - case "G": - // Old 'g' binding (toggle Global Arguments section), shifted - // to uppercase to make room for the set-var popup. - m.showGlobals = !m.showGlobals - return m, nil case "h", "left": if m.moveCursorHoriz(-1) { if idx := m.fieldAt(m.cursor); idx >= 0 { diff --git a/internal/ui/keys.go b/internal/ui/keys.go index bed12be..e85c003 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -4,30 +4,28 @@ import "github.com/charmbracelet/bubbles/key" // KeyMap holds all key bindings for the form (spec 6.6). type KeyMap struct { - Up key.Binding - Down key.Binding - Toggle key.Binding // Space: enable/disable param - Edit key.Binding // Enter in list: open value editor - Filter key.Binding // /: open filter - Tab key.Binding - ShiftTab key.Binding - Confirm key.Binding // Enter on Done button - Quit key.Binding // Esc/q in list: exit without result - ShowAll key.Binding // a: expand collapsed params - ShowGlobal key.Binding // G (shift+g): show global args + Up key.Binding + Down key.Binding + Toggle key.Binding // Space: enable/disable param + Edit key.Binding // Enter in list: open value editor + Filter key.Binding // /: open filter + Tab key.Binding + ShiftTab key.Binding + Confirm key.Binding // Enter on Done button + Quit key.Binding // Esc/q in list: exit without result + ShowAll key.Binding // a: expand collapsed params } // DefaultKeys matches the keyboard layout in spec 6.6. var DefaultKeys = KeyMap{ - Up: key.NewBinding(key.WithKeys("up", "k")), - Down: key.NewBinding(key.WithKeys("down", "j")), - Toggle: key.NewBinding(key.WithKeys(" ")), - Edit: key.NewBinding(key.WithKeys("enter")), - Filter: key.NewBinding(key.WithKeys("/")), - Tab: key.NewBinding(key.WithKeys("tab")), - ShiftTab: key.NewBinding(key.WithKeys("shift+tab")), - Confirm: key.NewBinding(key.WithKeys("enter")), - Quit: key.NewBinding(key.WithKeys("esc", "q")), - ShowAll: key.NewBinding(key.WithKeys("a")), - ShowGlobal: key.NewBinding(key.WithKeys("G")), + Up: key.NewBinding(key.WithKeys("up", "k")), + Down: key.NewBinding(key.WithKeys("down", "j")), + Toggle: key.NewBinding(key.WithKeys(" ")), + Edit: key.NewBinding(key.WithKeys("enter")), + Filter: key.NewBinding(key.WithKeys("/")), + Tab: key.NewBinding(key.WithKeys("tab")), + ShiftTab: key.NewBinding(key.WithKeys("shift+tab")), + Confirm: key.NewBinding(key.WithKeys("enter")), + Quit: key.NewBinding(key.WithKeys("esc", "q")), + ShowAll: key.NewBinding(key.WithKeys("a")), } diff --git a/internal/ui/model.go b/internal/ui/model.go index f5d157d..3c0d6a9 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -159,12 +159,6 @@ type Form struct { draftStore *state.DraftStore draftRestored bool - // showGlobals controls whether Azure CLI "Global Arguments" (--output, - // --query, --subscription, --verbose, --debug, etc.) are rendered in the - // optional list. Toggle with the 'g' key. Enabled globals always appear - // in the built command regardless of this flag. - showGlobals bool - staleWarn string quitting bool result string @@ -802,9 +796,6 @@ func (m *Form) widestName() int { w := gridMinNameCol for _, idx := range m.visible { f := &m.fields[idx] - if f.Param.Global && !m.showGlobals && !f.Enabled { - continue - } if n := len(f.Param.Name); n > w { w = n } @@ -831,9 +822,8 @@ func (m *Form) gridCellWidth() int { // Returns: // - roCols: Req+Opt field indices split across 1 or 2 columns // (required-first order preserved). -// - globalsCol: global field indices for the last column; empty when -// showGlobals is off and no globals are enabled AND there are no globals -// to hint about. +// - globalsCol: global field indices for the last column; empty only when +// the command has no global parameters. // - cols: total column count (1, 2, or 3). Callers use cols == 1 to fall // back to the single-column render path. // @@ -845,34 +835,12 @@ func (m *Form) gridLayout() (roCols [][]int, globalsCol []int, cols int) { return nil, nil, 1 } - // Count total globals to decide if they all fit vertically. When they do, - // show them all unconditionally (a dedicated column with room to spare has - // no reason to hide anything). When they don't fit, respect the 'g' toggle - // so users can collapse them to save space. - totalGlobals := 0 - for _, idx := range m.visible { - if m.fields[idx].Param.Global { - totalGlobals++ - } - } - availableRows := 20 // reasonable default before the first WindowSizeMsg - if m.vpReady && m.vp.Height > 0 { - availableRows = m.vp.Height - } - fitAll := totalGlobals <= availableRows - showAllGlobals := m.showGlobals || fitAll - // Partition visible fields into (req+opt) and (globals). var ro []int - var hasHiddenGlobal bool for _, idx := range m.visible { f := &m.fields[idx] if f.Param.Global { - if showAllGlobals || f.Enabled { - globalsCol = append(globalsCol, idx) - } else { - hasHiddenGlobal = true - } + globalsCol = append(globalsCol, idx) continue } ro = append(ro, idx) @@ -887,7 +855,7 @@ func (m *Form) gridLayout() (roCols [][]int, globalsCol []int, cols int) { // A globals column is "present" (occupies a slot) whenever the command // has any global arguments — visible or hidden-but-hintable. - globalPresent := len(globalsCol) > 0 || hasHiddenGlobal + globalPresent := len(globalsCol) > 0 // Req+Opt: 1 col unless count > threshold AND we have room for 2 ro cols // plus (if needed) the globals col. @@ -1073,9 +1041,6 @@ func (m Form) Visible() []int { return append([]int(nil), m.visible...) } // DraftRestored reports whether NewForm loaded a persisted draft. func (m Form) DraftRestored() bool { return m.draftRestored } -// ShowGlobals reports whether the "g" toggle is showing global params. -func (m Form) ShowGlobals() bool { return m.showGlobals } - // Quitting reports whether the form has signalled tea.Quit. func (m Form) Quitting() bool { return m.quitting } diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 4aa3de5..f289805 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -2125,93 +2125,33 @@ func TestLoadedFieldRowShowsOptionCount(t *testing.T) { } } -func TestFormShowGlobalsToggle(t *testing.T) { - params := []metadata.Parameter{ - {Name: "--name", Required: true, TakesValue: true, ValueKind: metadata.ValueKindString, Group: "Required Parameters"}, - {Name: "--tags", Required: false, TakesValue: true, ValueKind: metadata.ValueKindKeyValue, Group: "Optional Parameters"}, - {Name: "--output", Aliases: []string{"-o"}, Global: true, TakesValue: true, ValueKind: metadata.ValueKindEnum, Choices: []string{"json", "table", "tsv"}, Group: "Global Arguments"}, - {Name: "--query", Global: true, TakesValue: true, ValueKind: metadata.ValueKindString, Group: "Global Arguments"}, - } - f := ui.NewForm("group list", "/tmp/out.txt", t.TempDir(), "test", nil) - m, _ := f.Update(ui.MetadataLoadedMsg{Params: params, Summary: "."}) - f = m.(ui.Form) - - // Default: globals hidden; view shows the "press G" hint but not --output. - view := f.View() - if f.ShowGlobals() { - t.Error("ShowGlobals should default to false") - } - if strings.Contains(view, "--output") { - t.Error("view should NOT contain --output when showGlobals=false") - } - if !strings.Contains(view, "press G to show 2 global") { - t.Errorf("view should include the 'press G' hint with count; view:\n%s", view) - } - - // Press 'G' (uppercase; lowercase 'g' now opens the set-var popup) - // → globals visible. - m, _ = f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) - f = m.(ui.Form) - if !f.ShowGlobals() { - t.Fatal("ShowGlobals should be true after pressing G") - } - view = f.View() - if !strings.Contains(view, "--output") { - t.Errorf("view should contain --output after G; view:\n%s", view) - } - if !strings.Contains(view, "--query") { - t.Error("view should contain --query after G") - } - - // Press 'G' again → hidden again. - m, _ = f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) - f = m.(ui.Form) - if f.ShowGlobals() { - t.Error("ShowGlobals should toggle back to false") - } - if strings.Contains(f.View(), "--output") { - t.Error("view should not contain --output after second G") - } -} - -// TestShowGlobalsMovedToShiftG is the companion to TestFormShowGlobalsToggle: -// pressing lowercase 'g' from list mode must NOT toggle showGlobals anymore -// (it now opens the set-var popup). The old binding has been shifted to -// uppercase 'G'. Without this guard a future refactor could accidentally -// re-wire 'g' and silently break the new popup. -func TestShowGlobalsMovedToShiftG(t *testing.T) { +// TestLowercaseGOpensSetVarPopup guards the 'g' binding: it must open the +// set-var popup rather than doing anything else. It previously toggled the +// Global Arguments section, and that toggle has since been removed +// (globals always render — see TestGlobalsAlwaysVisible), but the guard on +// 'g' itself is still worth keeping so a future refactor cannot silently +// re-wire the popup key. +func TestLowercaseGOpensSetVarPopup(t *testing.T) { f := ui.NewForm("group list", "/tmp/out.txt", t.TempDir(), "test", nil) m, _ := f.Update(ui.MetadataLoadedMsg{Params: testParams, Summary: "."}) f = m.(ui.Form) - // Lowercase 'g' opens the popup, not the toggle. m, _ = f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}) f = m.(ui.Form) if f.Mode() != ui.FormModeSetVar { t.Fatalf("lowercase g should open FormModeSetVar, got %v", f.Mode()) } - if f.ShowGlobals() { - t.Error("lowercase g must not toggle ShowGlobals") - } - // Esc closes the popup; ShowGlobals is still false (uppercase only). m, _ = f.Update(tea.KeyMsg{Type: tea.KeyEsc}) f = m.(ui.Form) - if f.ShowGlobals() { - t.Error("Esc from popup must not change ShowGlobals") - } - - // Uppercase 'G' now toggles. - m, _ = f.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) - f = m.(ui.Form) - if !f.ShowGlobals() { - t.Error("uppercase G should toggle ShowGlobals") + if f.Mode() != ui.FormModeList { + t.Errorf("Esc should return to list mode, got %v", f.Mode()) } } // makeGridForm builds a Form loaded with a synthetic parameter set for grid // layout tests. reqOptCount includes reqCount as the first N required params. -func makeGridForm(t *testing.T, reqCount, reqOptCount, globalCount, termWidth int, showGlobals bool) ui.Form { +func makeGridForm(t *testing.T, reqCount, reqOptCount, globalCount, termWidth int) ui.Form { t.Helper() var params []metadata.Parameter for i := 0; i < reqCount; i++ { @@ -2240,9 +2180,6 @@ func makeGridForm(t *testing.T, reqCount, reqOptCount, globalCount, termWidth in f := ui.NewForm("test cmd", "/tmp/out", t.TempDir(), "test", nil) m, _ := f.Update(tea.WindowSizeMsg{Width: termWidth, Height: 40}) m, _ = m.(ui.Form).Update(ui.MetadataLoadedMsg{Params: params, Summary: "."}) - if showGlobals { - m, _ = m.(ui.Form).Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) - } return m.(ui.Form) } @@ -2253,24 +2190,22 @@ func TestGridLayout(t *testing.T) { reqCount int // subset of reqOpt that are required globals int termWidth int - showGlobals bool wantCols int wantRoCols int // len(roCols) wantGlobals int // len(globalsCol) }{ - {"narrow terminal → single col fallback", 5, 2, 3, 60, true, 1, 0, 0}, - {"mid width, few fields, globals shown", 5, 2, 3, 140, true, 2, 1, 3}, - {"mid width, many fields, globals shown → 3-col needs 3 cells; 140 fits ~3", 18, 3, 3, 200, true, 3, 2, 3}, - {"mid width, many fields, showGlobals off but globals fit → shown anyway", 18, 3, 3, 200, false, 3, 2, 3}, - {"wide, few fields → stays 2-col (count not > threshold)", 5, 2, 3, 240, true, 2, 1, 3}, - {"wide, many fields → 3-col (Req+Opt split)", 18, 3, 3, 240, true, 3, 2, 3}, - {"wide, many fields, no globals in metadata → 2-col", 18, 3, 0, 240, true, 2, 2, 0}, - {"exactly at threshold (10) → stays 1 Req+Opt col", 10, 2, 3, 240, true, 2, 1, 3}, - {"just over threshold (11) → splits", 11, 2, 3, 240, true, 3, 2, 3}, + {"narrow terminal → single col fallback", 5, 2, 3, 60, 1, 0, 0}, + {"mid width, few fields, globals shown", 5, 2, 3, 140, 2, 1, 3}, + {"mid width, many fields, globals shown → 3-col needs 3 cells; 140 fits ~3", 18, 3, 3, 200, 3, 2, 3}, + {"wide, few fields → stays 2-col (count not > threshold)", 5, 2, 3, 240, 2, 1, 3}, + {"wide, many fields → 3-col (Req+Opt split)", 18, 3, 3, 240, 3, 2, 3}, + {"wide, many fields, no globals in metadata → 2-col", 18, 3, 0, 240, 2, 2, 0}, + {"exactly at threshold (10) → stays 1 Req+Opt col", 10, 2, 3, 240, 2, 1, 3}, + {"just over threshold (11) → splits", 11, 2, 3, 240, 3, 2, 3}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - f := makeGridForm(t, tc.reqCount, tc.reqOpt, tc.globals, tc.termWidth, tc.showGlobals) + f := makeGridForm(t, tc.reqCount, tc.reqOpt, tc.globals, tc.termWidth) roCols, globalsCol, cols := f.GridLayout() if cols != tc.wantCols { t.Errorf("cols = %d, want %d", cols, tc.wantCols) @@ -2297,7 +2232,7 @@ func TestGridLayout(t *testing.T) { func TestGridRenderContainsAllFields(t *testing.T) { // 18 Req+Opt + 3 globals + wide terminal → 3-col grid; every field name // should appear in the rendered view. - f := makeGridForm(t, 3, 18, 3, 240, true) + f := makeGridForm(t, 3, 18, 3, 240) view := f.View() for i := 0; i < 18; i++ { want := fmt.Sprintf("--req%d", i) @@ -2316,7 +2251,7 @@ func TestGridRenderContainsAllFields(t *testing.T) { } func TestGridRenderSingleColFallbackInEditMode(t *testing.T) { - f := makeGridForm(t, 3, 18, 3, 240, true) + f := makeGridForm(t, 3, 18, 3, 240) // GridLayout should say 3 columns. _, _, cols := f.GridLayout() if cols != 3 { @@ -2386,7 +2321,7 @@ func lipglossFg(code string) lipgloss.Style { func TestCursorHorizontalNavigation(t *testing.T) { // 18 Req+Opt + 3 globals + wide terminal → 3-col grid (9|9|3). - f := makeGridForm(t, 3, 18, 3, 240, true) + f := makeGridForm(t, 3, 18, 3, 240) roCols, globalsCol, cols := f.GridLayout() if cols != 3 || len(roCols) != 2 || len(globalsCol) != 3 { t.Fatalf("precondition: expected 3 cols with 2 ro + 3 globals, got cols=%d ro=%v globals=%v", cols, roCols, globalsCol) @@ -2431,7 +2366,7 @@ func TestCursorHorizontalUnequalColumnHeights(t *testing.T) { // 12 Req+Opt (splits into 6|6) + 2 globals + wide → 3 cols with heights 6|6|2. // Navigate to bottom of col 0 (row 5), press 'l' → col 1 row 5. // Press 'l' again from col 1 row 5 → col 2 row 1 (clamped, since col 2 has only 2 rows). - f := makeGridForm(t, 2, 12, 2, 240, true) + f := makeGridForm(t, 2, 12, 2, 240) roCols, globalsCol, cols := f.GridLayout() if cols != 3 { t.Fatalf("precondition: expected 3 cols, got %d", cols) @@ -2981,7 +2916,7 @@ func pressUp(t *testing.T, f ui.Form) ui.Form { // TestVertNavStopsAtTopOfFirstColumn verifies Up at the top of column 0 // is a no-op (no wraparound, no crash). func TestVertNavStopsAtTopOfFirstColumn(t *testing.T) { - f := makeGridForm(t, 3, 18, 3, 240, true) + f := makeGridForm(t, 3, 18, 3, 240) before := f.Cursor() f = pressUp(t, f) if f.Cursor() != before { @@ -2992,7 +2927,7 @@ func TestVertNavStopsAtTopOfFirstColumn(t *testing.T) { // TestVertNavWrapsToPrevColBottom verifies Up at the top of a non-first // column jumps to the last row of the previous column. func TestVertNavWrapsToPrevColBottom(t *testing.T) { - f := makeGridForm(t, 3, 18, 3, 240, true) + f := makeGridForm(t, 3, 18, 3, 240) roCols, _, _ := f.GridLayout() // Jump directly to top of col 1 by advancing 9 Down presses (col 0 has 9). for i := 0; i < 9; i++ { @@ -3011,7 +2946,7 @@ func TestVertNavWrapsToPrevColBottom(t *testing.T) { // TestVertNavWrapsToNextColTop verifies Down at the last row of a // non-last column jumps to the top of the next column. func TestVertNavWrapsToNextColTop(t *testing.T) { - f := makeGridForm(t, 3, 18, 3, 240, true) + f := makeGridForm(t, 3, 18, 3, 240) roCols, _, _ := f.GridLayout() // Advance to last row of col 0 (8 presses lands at roCols[0][8]). for i := 0; i < 8; i++ { @@ -3029,7 +2964,7 @@ func TestVertNavWrapsToNextColTop(t *testing.T) { // TestVertNavStopsAtBottomOfLastColumn verifies Down at the last field // of the last column is a no-op. func TestVertNavStopsAtBottomOfLastColumn(t *testing.T) { - f := makeGridForm(t, 3, 18, 3, 240, true) + f := makeGridForm(t, 3, 18, 3, 240) roCols, globalsCol, _ := f.GridLayout() total := len(roCols[0]) + len(roCols[1]) + len(globalsCol) // Advance to the very last field (total-1 Down presses from index 0). @@ -3049,7 +2984,7 @@ func TestVertNavStopsAtBottomOfLastColumn(t *testing.T) { // TestVertNavSingleColumnStillWorks verifies flat-index Up/Down still // applies when the grid falls back to single-column mode. func TestVertNavSingleColumnStillWorks(t *testing.T) { - f := makeGridForm(t, 2, 5, 3, 60, true) // narrow term → single col + f := makeGridForm(t, 2, 5, 3, 60) // narrow term → single col _, _, cols := f.GridLayout() if cols != 1 { t.Fatalf("precondition: expected cols=1, got %d", cols) @@ -3129,7 +3064,7 @@ func nameOfField(f ui.Form, idx int) string { // times from the top visits every field in visual grid order (col 0 // top-to-bottom, then col 1, then globals). This is the bug reproducer. func TestVertNavSweepFollowsVisualOrder(t *testing.T) { - f := makeGridForm(t, 3, 18, 3, 240, true) + f := makeGridForm(t, 3, 18, 3, 240) roCols, globalsCol, _ := f.GridLayout() want := make([]int, 0) want = append(want, roCols[0]...) diff --git a/internal/ui/view.go b/internal/ui/view.go index 52397c1..c8e8d68 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -195,7 +195,6 @@ func (m Form) View() string { } default: var optSB strings.Builder - hiddenGlobals := 0 cursorRow := -1 row := 0 focusIdx := m.fieldAt(m.cursor) @@ -204,19 +203,12 @@ func (m Form) View() string { if f.Param.Required { continue } - if f.Param.Global && !m.showGlobals && !f.Enabled { - hiddenGlobals++ - continue - } if idx == focusIdx { cursorRow = row } writeLine(&optSB, m.renderFieldSelected(idx, idx == focusIdx, nameWidth)) row++ } - if hiddenGlobals > 0 { - writeLine(&optSB, hintStyle.Render(fmt.Sprintf(" (press G to show %d global argument(s))", hiddenGlobals))) - } if m.vpReady { m.vp.SetContent(optSB.String()) if cursorRow >= 0 { @@ -448,7 +440,6 @@ func (m Form) renderHelp() string { {"esc", "close popup / cancel edit"}, {"/", "filter visible parameters"}, {"g", "set a shell variable (writes export to calling shell)"}, - {"G", "toggle Global Arguments section"}, }, }, { @@ -521,9 +512,6 @@ func (m *Form) nameColumnWidth() int { if f.Param.Required { continue } - if f.Param.Global && !m.showGlobals && !f.Enabled { - continue - } widen(idx) } return width @@ -825,31 +813,6 @@ func (m *Form) renderGrid(roCols [][]int, globalsCol []int) (body string, cursor cursorRow = -1 focusedCol = -1 - // Count hidden globals so we can emit a "press g" hint. Mirrors the - // fit-vs-toggle logic in gridLayout: if all globals fit vertically, none - // are hidden — no hint needed. - hiddenGlobals := 0 - if !m.showGlobals { - totalGlobals := 0 - for _, idx := range m.visible { - if m.fields[idx].Param.Global { - totalGlobals++ - } - } - availableRows := 20 - if m.vpReady && m.vp.Height > 0 { - availableRows = m.vp.Height - } - if totalGlobals > availableRows { - for _, idx := range m.visible { - f := &m.fields[idx] - if f.Param.Global && !f.Enabled { - hiddenGlobals++ - } - } - } - } - var sb strings.Builder for row := 0; row < maxH; row++ { var line strings.Builder @@ -883,17 +846,6 @@ func (m *Form) renderGrid(roCols [][]int, globalsCol []int) (body string, cursor sb.WriteByte('\n') } - // Hint: when globals were auto-hidden because they don't fit vertically, - // let the user know 'G' will reveal them. - if hiddenGlobals > 0 { - if len(globalsCol) > 0 { - sb.WriteString(hintStyle.Render(fmt.Sprintf(" (press G to show %d more global argument(s))", hiddenGlobals))) - } else { - sb.WriteString(hintStyle.Render(fmt.Sprintf(" (press G to show %d global argument(s))", hiddenGlobals))) - } - sb.WriteByte('\n') - } - return sb.String(), cursorRow, focusedCol, focusedFullValue }