fix: allow digits in port forward fields

This commit is contained in:
mirivlad 2026-08-13 22:26:19 +08:00
parent 604a4ecde2
commit 10bcc07601
4 changed files with 336 additions and 2 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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

View File

@ -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)
}
})
}
}
}