sshkeeper/docs/superpowers/plans/2026-08-13-tui-forward-digi...

6.2 KiB

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:

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:

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:

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:

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:

import (
	"fmt"
	"testing"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/mirivlad/sshkeeper/internal/model"
)

The resulting import block must replace the original block. Append:

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:

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:

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:

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