From 10bcc07601b2ea97a345cf9ff234ed10975b5ada Mon Sep 17 00:00:00 2001 From: mirivlad Date: Thu, 13 Aug 2026 22:26:19 +0800 Subject: [PATCH] fix: allow digits in port forward fields --- .../2026-08-13-tui-forward-digit-input.md | 213 ++++++++++++++++++ ...26-08-13-tui-forward-digit-input-design.md | 56 +++++ internal/tui/forward.go | 4 +- internal/tui/forward_test.go | 65 ++++++ 4 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-13-tui-forward-digit-input.md create mode 100644 docs/superpowers/specs/2026-08-13-tui-forward-digit-input-design.md create mode 100644 internal/tui/forward_test.go diff --git a/docs/superpowers/plans/2026-08-13-tui-forward-digit-input.md b/docs/superpowers/plans/2026-08-13-tui-forward-digit-input.md new file mode 100644 index 0000000..1837488 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-tui-forward-digit-input.md @@ -0,0 +1,213 @@ +# TUI Port Forward Digit Input Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow `1`, `2`, and `3` to be entered in port-forward text fields without removing the existing type-selection shortcuts. + +**Architecture:** Keep the current event-routing structure and make the digit shortcut context-sensitive. The shortcut block will run only while one of the three type selector rows has focus; otherwise the existing focused `textinput` receives the key event. + +**Tech Stack:** Go 1.25+, Bubble Tea, Bubbles `textinput`, standard Go testing. + +## Global Constraints + +- Touch only the port-forward form and its focused regression tests. +- Preserve `1/2/3` type selection on the type selector. +- Preserve all existing navigation, validation, persistence, and help text. +- Add no dependencies or abstractions. + +--- + +### Task 1: Context-sensitive digit shortcuts + +**Files:** +- Create: `internal/tui/forward_test.go` +- Modify: `internal/tui/forward.go:320-340` +- Verify: `internal/tui/form.go`, `internal/tui/template_form.go`, `internal/tui/app.go` + +**Interfaces:** +- Consumes: `forwardFormModel.Update(tea.Msg) (tea.Model, tea.Cmd)`, `forwardTypes`, and the existing focus index layout where indices `2` through `2+len(forwardTypes)-1` are type selector rows. +- Produces: unchanged public and package interfaces; only event precedence changes inside `forwardFormModel.Update`. + +- [ ] **Step 1: Add the failing digit-entry regression test** + +Create `internal/tui/forward_test.go` with a test that focuses the listen-port +input and sends real Bubble Tea key messages one at a time: + +```go +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/mirivlad/sshkeeper/internal/model" +) + +func TestForwardFormDigitsReachFocusedInput(t *testing.T) { + fm := newForwardFormModel(1, 100, 30) + fm.focusIdx = 2 + len(forwardTypes) + 1 + fm.updateFocus() + + for _, digit := range []rune{'1', '2', '3'} { + updated, _ := fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{digit}}) + fm = updated.(*forwardFormModel) + } + + if got := fm.inputs[1].Value(); got != "123" { + t.Fatalf("listen port = %q, want %q", got, "123") + } + if fm.currentType != model.ForwardLocal { + t.Fatalf("forward type = %q, want %q", fm.currentType, model.ForwardLocal) + } +} +``` + +- [ ] **Step 2: Run the regression test and confirm the existing bug** + +Run: + +```bash +go test ./internal/tui -run '^TestForwardFormDigitsReachFocusedInput$' -count=1 +``` + +Expected result: FAIL because the listen-port value is empty; `1`, `2`, and +`3` were consumed by the global type shortcut block. + +- [ ] **Step 3: Gate digit shortcuts by selector focus** + +Change the existing `tea.KeyRunes` condition in `internal/tui/forward.go` to: + +```go +case tea.KeyRunes: + // Direct number keys select a type only while the type selector has focus. + if fm.focusIdx >= 2 && fm.focusIdx < 2+len(forwardTypes) && len(msg.Runes) == 1 { + switch msg.Runes[0] { + case '1': + fm.typeIdx = 0 + fm.currentType = model.ForwardLocal + fm.updateFocus() + return fm, nil + case '2': + fm.typeIdx = 1 + fm.currentType = model.ForwardRemote + fm.updateFocus() + return fm, nil + case '3': + fm.typeIdx = 2 + fm.currentType = model.ForwardDynamic + fm.updateFocus() + return fm, nil + } + } +``` + +- [ ] **Step 4: Confirm the digit-entry regression is fixed** + +Run: + +```bash +go test ./internal/tui -run '^TestForwardFormDigitsReachFocusedInput$' -count=1 +``` + +Expected result: PASS. + +- [ ] **Step 5: Add shortcut-preservation coverage** + +Add `"fmt"` to the existing import block, then append this independent test to +`internal/tui/forward_test.go`: + +```go +import ( + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/mirivlad/sshkeeper/internal/model" +) +``` + +The resulting import block must replace the original block. Append: + +```go +func TestForwardFormDigitShortcutsWorkOnTypeSelector(t *testing.T) { + tests := []struct { + digit rune + want model.ForwardType + idx int + }{ + {digit: '1', want: model.ForwardLocal, idx: 0}, + {digit: '2', want: model.ForwardRemote, idx: 1}, + {digit: '3', want: model.ForwardDynamic, idx: 2}, + } + + for focusIdx := 2; focusIdx < 2+len(forwardTypes); focusIdx++ { + for _, tt := range tests { + t.Run(fmt.Sprintf("focus_%d_digit_%c", focusIdx, tt.digit), func(t *testing.T) { + fm := newForwardFormModel(1, 100, 30) + fm.currentType = model.ForwardDynamic + fm.typeIdx = typeIndex(model.ForwardDynamic) + if tt.want == model.ForwardDynamic { + fm.currentType = model.ForwardLocal + fm.typeIdx = typeIndex(model.ForwardLocal) + } + fm.focusIdx = focusIdx + fm.updateFocus() + + updated, _ := fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{tt.digit}}) + fm = updated.(*forwardFormModel) + + if fm.currentType != tt.want { + t.Fatalf("forward type = %q, want %q", fm.currentType, tt.want) + } + if fm.typeIdx != tt.idx { + t.Fatalf("type index = %d, want %d", fm.typeIdx, tt.idx) + } + }) + } + } +} +``` + +- [ ] **Step 6: Run focused and full verification** + +Run: + +```bash +gofmt -w internal/tui/forward.go internal/tui/forward_test.go +go test ./internal/tui -run '^TestForwardFormDigit' -count=1 +go test ./... +go vet ./... +git diff --check +``` + +Expected result: both focused tests pass, all repository tests pass, `go vet` +exits successfully, and `git diff --check` prints no errors. + +- [ ] **Step 7: Commit the reviewed fix** + +Run: + +```bash +git add docs/superpowers/specs/2026-08-13-tui-forward-digit-input-design.md \ + docs/superpowers/plans/2026-08-13-tui-forward-digit-input.md \ + internal/tui/forward.go internal/tui/forward_test.go +git commit -m "fix: allow digits in port forward fields" +``` + +Expected result: one commit containing only the design, plan, regression tests, +and minimal production fix. + +- [ ] **Step 8: Build and inspect the user-testable binary** + +Run: + +```bash +./build.sh +file /home/mirivlad/git/sshkeeper/bin/sshkeeper +sha256sum /home/mirivlad/git/sshkeeper/bin/sshkeeper +git status --short --branch +``` + +Expected result: `./build.sh` exits successfully, the file is a native +executable at `/home/mirivlad/git/sshkeeper/bin/sshkeeper`, a checksum is +printed, and the tracked working tree is clean. diff --git a/docs/superpowers/specs/2026-08-13-tui-forward-digit-input-design.md b/docs/superpowers/specs/2026-08-13-tui-forward-digit-input-design.md new file mode 100644 index 0000000..0d94390 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-tui-forward-digit-input-design.md @@ -0,0 +1,56 @@ +# TUI Port Forward Digit Input Design + +## Problem + +The port-forward form displays `1`, `2`, and `3` as shortcuts for Local, +Remote, and SOCKS. Its `Update` method currently handles those runes before it +routes the event to the focused `textinput`, regardless of which control has +focus. As a result, typing any of those digits into a name, description, +address, or port field changes the forward type and drops the character. + +## Scope + +- Preserve the existing `1/2/3` type-selection shortcuts while focus is on any + of the three type selector rows. +- Treat `1`, `2`, and `3` as ordinary input everywhere else in the form. +- Add focused regression coverage for digit entry and shortcut preservation. +- Do not change form navigation, validation, persistence, or unrelated TUI + behavior. + +## Design + +Gate the existing digit shortcut block on the type selector focus range: +`focusIdx >= 2 && focusIdx < 2+len(forwardTypes)`. When that condition is +false, processing falls through to the existing focused-input routing. No new +state, helper, or dependency is needed. + +The type selector itself remains keyboard-accessible through Tab/arrow +navigation, Enter, and direct `1/2/3` selection. The footer remains accurate. + +## Regression Coverage + +1. Focus the listen-port input, send separate Bubble Tea key messages for + `1`, `2`, and `3`, and assert that the field contains `123` and the forward + type remains Local. +2. For every type selector row, assert that each direct digit still selects + the corresponding Local, Remote, or SOCKS type and index, starting from a + deliberately different type so no case can pass from the default state. + +## Similar-Error Audit + +All `tea.KeyRunes` branches and all `textinput.Model.Update` call sites under +`internal/tui` were inspected. The server form's `/` shortcut is explicitly +limited to the Auth Method and Group selector fields. Search, tag, and template +forms do not intercept printable shortcut keys before their text inputs. The +remaining rune shortcuts are confined to non-input screens (lists, help, +confirmation, and mode selection). No second instance of this bug class was +found. + +## Verification + +- Demonstrate that the new regression test fails against the current code. +- Apply the one-condition production fix and demonstrate that the focused + tests pass. +- Run `go test ./...`, `go vet ./...`, and `./build.sh`. +- Confirm the final binary exists at + `/home/mirivlad/git/sshkeeper/bin/sshkeeper` and report its metadata. diff --git a/internal/tui/forward.go b/internal/tui/forward.go index b4f35bb..5537b47 100644 --- a/internal/tui/forward.go +++ b/internal/tui/forward.go @@ -318,8 +318,8 @@ func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { fm.updateFocus() return fm, nil case tea.KeyRunes: - // Direct number key to select type - if len(msg.Runes) == 1 { + // Direct number keys select a type only while the type selector has focus. + if fm.focusIdx >= 2 && fm.focusIdx < 2+len(forwardTypes) && len(msg.Runes) == 1 { switch msg.Runes[0] { case '1': fm.typeIdx = 0 diff --git a/internal/tui/forward_test.go b/internal/tui/forward_test.go new file mode 100644 index 0000000..3e92957 --- /dev/null +++ b/internal/tui/forward_test.go @@ -0,0 +1,65 @@ +package tui + +import ( + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/mirivlad/sshkeeper/internal/model" +) + +func TestForwardFormDigitsReachFocusedInput(t *testing.T) { + fm := newForwardFormModel(1, 100, 30) + fm.focusIdx = 2 + len(forwardTypes) + 1 + fm.updateFocus() + + for _, digit := range []rune{'1', '2', '3'} { + updated, _ := fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{digit}}) + fm = updated.(*forwardFormModel) + } + + if got := fm.inputs[1].Value(); got != "123" { + t.Fatalf("listen port = %q, want %q", got, "123") + } + if fm.currentType != model.ForwardLocal { + t.Fatalf("forward type = %q, want %q", fm.currentType, model.ForwardLocal) + } +} + +func TestForwardFormDigitShortcutsWorkOnTypeSelector(t *testing.T) { + tests := []struct { + digit rune + want model.ForwardType + idx int + }{ + {digit: '1', want: model.ForwardLocal, idx: 0}, + {digit: '2', want: model.ForwardRemote, idx: 1}, + {digit: '3', want: model.ForwardDynamic, idx: 2}, + } + + for focusIdx := 2; focusIdx < 2+len(forwardTypes); focusIdx++ { + for _, tt := range tests { + t.Run(fmt.Sprintf("focus_%d_digit_%c", focusIdx, tt.digit), func(t *testing.T) { + fm := newForwardFormModel(1, 100, 30) + fm.currentType = model.ForwardDynamic + fm.typeIdx = typeIndex(model.ForwardDynamic) + if tt.want == model.ForwardDynamic { + fm.currentType = model.ForwardLocal + fm.typeIdx = typeIndex(model.ForwardLocal) + } + fm.focusIdx = focusIdx + fm.updateFocus() + + updated, _ := fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{tt.digit}}) + fm = updated.(*forwardFormModel) + + if fm.currentType != tt.want { + t.Fatalf("forward type = %q, want %q", fm.currentType, tt.want) + } + if fm.typeIdx != tt.idx { + t.Fatalf("type index = %d, want %d", fm.typeIdx, tt.idx) + } + }) + } + } +}