Skip to content

feat(tui): caret editing + paste normalization across input dialogs#61

Open
Magentron wants to merge 11 commits intoMiniCodeMonkey:mainfrom
Magentron:feature/allow-to-use-cursor-keys-on-entering-prd-name
Open

feat(tui): caret editing + paste normalization across input dialogs#61
Magentron wants to merge 11 commits intoMiniCodeMonkey:mainfrom
Magentron:feature/allow-to-use-cursor-keys-on-entering-prd-name

Conversation

@Magentron
Copy link
Copy Markdown

Summary

  • Replaces the hand-rolled key handlers in all three TUI text inputs (FirstTimeSetup PRD-name, PRDPicker new-PRD-name, BranchWarning branch-name) with bubbles/textinput,
    so Left/Right/Home/End/Ctrl+Left/Ctrl+Right and clipboard paste now behave like a normal terminal input. Charset filtering, max-length caps, blink/focus wiring, and width-sync
    are preserved.
  • Adds paste normalization: runs of invalid characters in pasted text collapse to a single - (with leading/trailing invalid runes stripped) instead of being silently
    dropped, so "my feature/v2!""my-feature-v2" and "feat/oops bad!""feat/oops-bad". Detected via bracketed-paste flag or multi-rune KeyRunes fallback so terminals
    without bracketed-paste get the same UX. Single-keystroke typing keeps the drop-based filter.
  • Fixes ctrl+c being swallowed in picker input mode and branch-warning edit mode (previously slipped through to the textinput and did nothing — now routes through tryQuit just
    like FirstTimeSetup).

Scope

Implemented as a user-story series (US-001..US-009) followed by two targeted follow-ups:

Story Scope
US-001..US-006 FirstTimeSetup PRD name: adopt bubbles/textinput, caret keys, paste-with-filter, max length, test matrix
US-007 PRDPicker new-PRD-name: same adoption
US-008 BranchWarning branch-name: same adoption
US-009 Test matrix parity for picker + branch-warning
ctrl+c dispatch fix in picker + branch-warning input modes
US-004a Paste normalization (interior invalid runs → -, ends stripped)

New shared helpers in internal/tui/input_filters.go: isAllowedPRDNameRune, isAllowedBranchNameRune, isTextualKey, isPasteLike, filterRunes, normalizePastedRunes,
and the word-jump helpers (wordBackward/wordForward).

Manager.SetInstanceState added to internal/loop/manager.go so tests can force a running loop without spinning up a Provider — unblocks the ctrl+c → quit-confirm regression
tests (the previous inst := m.GetInstance(...); inst.State = ... pattern mutated a copy; see the GetInstance docstring comment "Return a copy to avoid race conditions").

New dependency: github.com/charmbracelet/bubbles (first-party charmbracelet component library, same author as bubbletea which is already a direct dependency).

Test plan

  • go test ./... passes locally.
  • Manual: open first-time setup, press n nothing — type a PRD name using Left/Right/Home/End/Ctrl+Left/Ctrl+Right; caret moves as expected, invalid single keystrokes are
    silently dropped.
  • Manual: paste "my feature/v2!" in the PRD-name field → "my-feature-v2".
  • Manual: in the main TUI, press n, paste a name with invalid chars → interior runs collapse to -, trailing invalid stripped, cursor keys work.
  • Manual: open the branch-warning modal (trigger a protected-branch path), press e, paste "feat/oops bad!""feat/oops-bad" (note / is preserved — it's in the branch
    charset).
  • Manual: ctrl+c in picker input mode with no running loop → app quits cleanly. Ctrl+C with a running loop → quit-confirmation dialog opens; Esc returns to the picker with
    the in-progress input preserved.
  • Manual: paste >64 chars → value truncates to maxPRDNameLength; paste >255 chars in branch-warning → truncates to maxBranchNameLength.
  • Manual: typing at max length is a silent no-op (no error, value unchanged).
  • Regression: existing FirstTimeSetup flows (esc → back to gitignore step / quit, Enter → validate + advance) unchanged.

Notes for reviewer

  • The CLI's isValidPRDName in internal/cmd/new.go is intentionally left alone — the length cap is TUI-only per US-005 Non-Goals.
  • Paste normalization is scoped to the pasted content; no cross-boundary dedupe against existing field text (so pasting -xyz into a field ending in - yields --xyz, by
    design — confirmed with product).
  • go.mod bumps 1.24.0 → 1.24.2 and adds bubbles; transitive charm deps shift accordingly.

🤖 Generated with Claude Code

Chief Agent and others added 11 commits April 23, 2026 09:01
Replace the hand-rolled PRD-name key handler in FirstTimeSetup with a
bubbles/textinput.Model so caret editing, word-jump, and the rest of the
default bindings work without us owning them. The textinput's Value() is
the single source of truth; the raw prdName string field is gone. Ctrl+C,
Esc, and Enter keep their existing custom semantics and are matched first;
all other key messages are forwarded to ti.Update after filtering
msg.Runes against [a-zA-Z0-9_-]. Init and confirmGitignore both emit
textinput.Blink so the caret blinks on entry in both flows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The `bubbles/textinput` model adopted in US-001 already implements the
required key bindings (Left/Right, Home/End, Ctrl+Left/Right, Backspace
at cursor, rune insertion at cursor, visible blinking caret) via its
default KeyMap. This change adds a regression test suite that locks in
each acceptance criterion against the FirstTimeSetup wrapper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…or unchanged

Filter both KeyRunes and KeySpace messages through filterValidPRDRunes so a
bare spacebar press is silently dropped. The previous filter only looked at
KeyRunes; bubbletea reports a single space as KeyMsg{Type: KeySpace,
Runes: []rune{' '}}, which slipped past the gate and inserted a literal space
into the buffer.

strings.TrimSpace is intentionally retained on submit as a defensive
belt-and-braces check even though whitespace can no longer enter the buffer
under normal input - cheap to keep, removes a line of subtle behavior coupling
to the input filter.

Adds regression tests for: spacebar filtering, multi-byte Unicode rune
filtering (close the corner case the old byte-length check missed), the
exact "Name cannot be empty" error string, error clearing on value change,
error preservation when a key is fully filtered out, ctrl+c cancel under
both showGitignore branches, esc cancel without gitignore, esc-back to
gitignore step (also clearing the error), textinput Width tracking the
modal content width on resize, and visual width parity between empty and
populated rendered fields.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The existing rune-filter path (filterValidPRDRunes over KeyRunes /
KeySpace) and the textinput's built-in paste/CharLimit handling already
satisfy every acceptance criterion — bracketed paste arrives as a single
KeyMsg{Type: KeyRunes, Paste: true}, which the filter scrubs before the
textinput splices the survivors at the caret and truncates to CharLimit.

Lock the behaviour in with regression tests covering:
- AC1: all-valid paste inserts at caret in one step
- AC2: mixed paste keeps only the valid subset, no error shown
- AC3: overlong paste truncates to maxPRDNameLength with prefix preserved
- AC4: mid-buffer paste splices (plus a filtering+splice combo)
- AC5: paste that changes value clears prdNameError (and the sister
  case where an all-invalid paste leaves a standing error untouched)
- fallback: multi-rune KeyRunes without Paste=true is filtered too
The maxPRDNameLength constant from US-001 already governs the textinput's
CharLimit (which bubbles uses for both keystroke and paste-time length
enforcement), and no other call site hard-codes a length. This story
adds regression tests so any future refactor that drifts from the
constant or reintroduces a hard-coded limit fails loudly:

- TestPRDName_CharLimitMatchesConstant pins ti.CharLimit == maxPRDNameLength
- TestPRDName_TypingAtMaxLengthIsNoOp verifies typing at the max length is
  silent (no value change, no cursor advance, no error message) —
  consistent with how invalid-character filtering already behaves.
End-to-end regression suite driven through Update(...) for the PRD-name
field: Left/Home/Ctrl+Left caret editing, paste filtering + truncation,
max-length no-op, empty-submit error, and Init/gitignore→PRDName
transition cmd wiring.

Also adds a `-`/`_`-aware word-jump intercept in handlePRDNameKeys so
Ctrl+Left on `foo-bar` lands after the `-` (bubbles' built-in
wordBackward is whitespace-only and would jump to pos 0, since our
filter strips whitespace from the buffer).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…me input

Replace the hand-rolled AddInputChar/DeleteInputChar accumulator in PRDPicker
with a bubbles/textinput.Model so the picker's new-PRD-name input gets caret
editing, paste with charset filtering, and a max-length cap — matching the
FirstTimeSetup StepPRDName behavior from the predecessor PRD.

- Move the charset filter and word-jump helpers out of first_time_setup.go and
  into a new internal/tui/input_filters.go (filterPRDNameRunes, wordBackward,
  wordForward, prdNameSeparators) so both widgets share one set of code paths
  and can't drift on charset or separator rules.
- PRDPicker.StartInputMode now focuses the textinput and returns
  textinput.Blink; app.go propagates that cmd at both call sites (app.go:608
  and app.go:1907) so the caret renders (FR-10). CancelInputMode blurs and
  clears.
- PRDPicker.UpdateInput implements the intercept-then-forward rune filter
  (KeyRunes + KeySpace) and overrides Ctrl+Left/Right + Alt-variants to treat
  \`-\` and \`_\` as word separators. app.go's input-mode dispatch now forwards
  the raw tea.KeyMsg after matching esc/enter, closing the multi-byte-rune
  corner case that the old \`len(msg.String()) == 1\` check silently dropped.
- renderInputMode now reads p.ti.View(), deleting the hand-drawn cursor
  block and the placeholder special-casing (both moved onto the textinput
  itself).
- first_time_setup_test.go's TestFilterValidPRDRunes body now references the
  new filterPRDNameRunes name in the same commit as the helper move.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…-name edit

The branch-name editor in the branch-warning modal now delegates to
`bubbles/textinput`, giving the user caret editing, paste, and a 255-
character cap (chosen so a long CLI-created PRD name can be seeded into
`chief/<name>` without silent truncation). `AddInputChar`/`DeleteInputChar`
are gone; `UpdateInput(tea.KeyMsg)` is the single entry point, with
`filterBranchNameRunes` ([a-zA-Z0-9_/-]) and Ctrl+Left/Right word jumps
that treat '-', '_', and '/' as separators so `chief/auth-system` jumps
in path-aware hops. `StartEditMode()` now returns `tea.Cmd` and the
`e` dispatch in app.go propagates it so the caret actually blinks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…th in both widgets

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ctrl+C was silently swallowed by the bubbles textinput in picker input
mode and branch-warning edit mode, diverging from FirstTimeSetup's
PRD-name step. Handle it in the same way (quit / open quit-confirm
when a loop is running) and advertise it in the footer shortcuts.

Also: extract isTextualKey() helper, cite the PRD path from the
UpdateInput doc comments, and mark CursorEnd() on edit-mode re-entry as
intentional. Adds regression tests for both the immediate-quit and
quit-confirm branches; the latter also asserts the in-progress
input/edit value survives canceling the confirmation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pastes into the three TUI text inputs (FirstTimeSetup PRD-name,
PRDPicker new-PRD-name, BranchWarning branch-name) previously silently
dropped invalid characters, mashing words together (e.g. "my feature/v2!"
→ "myfeaturev2"). Paste now goes through normalizePastedRunes: runs of
invalid characters collapse to a single '-', leading/trailing invalid
characters are stripped, and consecutive '-' dedupe within the pasted
content (normalization is scoped to the paste — no cross-boundary dedupe
against existing field text). "my feature/v2!" → "my-feature-v2" and
"feat/oops bad!" → "feat/oops-bad" for the branch input.

Paste detection (isPasteLike) covers bracketed paste (msg.Paste=true)
and the non-bracketed fallback (multi-rune KeyRunes without Paste);
single-rune typing keeps the original drop-based filter.

Also fixes an unrelated pre-existing bug surfaced by running the ctrl+c
tests: managerWithRunningPRD mutated a copy returned by
loop.Manager.GetInstance (see the "Return a copy to avoid race
conditions" comment), so IsAnyRunning always reported false and tryQuit
fell through to tea.Quit. Added Manager.SetInstanceState for tests that
need to force state without a Provider.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant