From df6397c33383929561a436c3d1c6243137a993a4 Mon Sep 17 00:00:00 2001 From: Chief Agent Date: Thu, 23 Apr 2026 09:01:53 +0000 Subject: [PATCH 01/11] feat: US-001 - Adopt `bubbles/textinput` for the PRD name field 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 --- go.mod | 23 ++++--- go.sum | 52 +++++++++------- internal/tui/first_time_setup.go | 100 +++++++++++++++++++++---------- 3 files changed, 112 insertions(+), 63 deletions(-) diff --git a/go.mod b/go.mod index 63b53d00..d0b88617 100644 --- a/go.mod +++ b/go.mod @@ -1,31 +1,36 @@ module github.com/minicodemonkey/chief -go 1.24.0 +go 1.24.2 require ( github.com/alecthomas/chroma/v2 v2.23.1 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/charmbracelet/x/term v0.2.1 github.com/fsnotify/fsnotify v1.9.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/glamour v0.10.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect @@ -36,7 +41,7 @@ require ( github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index a5fd2d79..4c9ef397 100644 --- a/go.sum +++ b/go.sum @@ -4,30 +4,40 @@ github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -38,15 +48,15 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -68,18 +78,16 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/tui/first_time_setup.go b/internal/tui/first_time_setup.go index 6c3725f6..92ef3d94 100644 --- a/internal/tui/first_time_setup.go +++ b/internal/tui/first_time_setup.go @@ -5,11 +5,27 @@ import ( "regexp" "strings" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/minicodemonkey/chief/internal/git" ) +// maxPRDNameLength caps the PRD name input in the first-time-setup TUI. +// TUI-only guard; the CLI in cmd/new.go does not enforce a length. +const maxPRDNameLength = 64 + +// prdNameModalWidth computes the PRD-name modal's content width from the +// terminal width. The formula is kept in one place so the textinput's Width +// stays in parity with the surrounding lipgloss box. +func prdNameModalWidth(terminalWidth int) int { + modalWidth := min(60, terminalWidth-10) + if modalWidth < 45 { + modalWidth = 45 + } + return modalWidth +} + // ghCheckResultMsg is sent when the gh CLI check completes. type ghCheckResultMsg struct { installed bool @@ -48,7 +64,7 @@ type FirstTimeSetup struct { gitignoreSelected int // 0 = Yes, 1 = No // PRD name step - prdName string + ti textinput.Model prdNameError string // Post-completion config step @@ -72,12 +88,22 @@ func NewFirstTimeSetup(baseDir string, showGitignore bool) *FirstTimeSetup { if showGitignore { step = StepGitignore } + + ti := textinput.New() + ti.Prompt = "" + ti.Placeholder = "" + ti.CharLimit = maxPRDNameLength + ti.Width = prdNameModalWidth(0) - 8 + ti.SetValue("main") + ti.CursorEnd() + ti.Focus() + return &FirstTimeSetup{ baseDir: baseDir, showGitignore: showGitignore, step: step, gitignoreSelected: 0, // Default to "Yes" - prdName: "main", + ti: ti, pushSelected: 0, // Default to "Yes" createPRSelected: 0, // Default to "Yes" } @@ -85,7 +111,10 @@ func NewFirstTimeSetup(baseDir string, showGitignore bool) *FirstTimeSetup { // Init initializes the model. func (f FirstTimeSetup) Init() tea.Cmd { - return tea.EnterAltScreen + if f.showGitignore { + return tea.EnterAltScreen + } + return tea.Batch(tea.EnterAltScreen, textinput.Blink) } // Update handles messages. @@ -94,6 +123,7 @@ func (f FirstTimeSetup) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: f.width = msg.Width f.height = msg.Height + f.ti.Width = prdNameModalWidth(f.width) - 8 return f, nil case ghCheckResultMsg: @@ -157,7 +187,10 @@ func (f FirstTimeSetup) confirmGitignore() (tea.Model, tea.Cmd) { } } f.step = StepPRDName - return f, nil + if !f.ti.Focused() { + f.ti.Focus() + } + return f, textinput.Blink } func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -178,7 +211,7 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": // Validate PRD name - name := strings.TrimSpace(f.prdName) + name := strings.TrimSpace(f.ti.Value()) if name == "" { f.prdNameError = "Name cannot be empty" return f, nil @@ -190,27 +223,37 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { f.result.PRDName = name f.step = StepPostCompletion return f, nil + } - case "backspace": - if len(f.prdName) > 0 { - f.prdName = f.prdName[:len(f.prdName)-1] - f.prdNameError = "" - } - return f, nil + // Filter rune input against the allowed character set before forwarding to + // the textinput. Non-rune keys (arrows, backspace, Home/End, Ctrl+arrows, + // Alt+Backspace, etc.) pass through unchanged so the bubbles default key + // bindings keep working. + if msg.Type == tea.KeyRunes { + msg.Runes = filterValidPRDRunes(msg.Runes) + } - default: - // Handle character input - if len(msg.String()) == 1 { - r := rune(msg.String()[0]) - // Only allow valid characters - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '-' || r == '_' { - f.prdName += string(r) - f.prdNameError = "" - } + before := f.ti.Value() + var cmd tea.Cmd + f.ti, cmd = f.ti.Update(msg) + if f.ti.Value() != before { + f.prdNameError = "" + } + return f, cmd +} + +// filterValidPRDRunes drops any rune outside the allowed PRD-name character +// set ([a-zA-Z0-9_-]). Returns a new slice so the caller can safely forward +// the filtered KeyMsg to the textinput. +func filterValidPRDRunes(runes []rune) []rune { + filtered := make([]rune, 0, len(runes)) + for _, r := range runes { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' { + filtered = append(filtered, r) } - return f, nil } + return filtered } // isValidPRDName checks if a name is valid for a PRD. @@ -466,10 +509,7 @@ func (f FirstTimeSetup) renderGitignoreStep() string { } func (f FirstTimeSetup) renderPRDNameStep() string { - modalWidth := min(60, f.width-10) - if modalWidth < 45 { - modalWidth = 45 - } + modalWidth := prdNameModalWidth(f.width) var content strings.Builder @@ -500,11 +540,7 @@ func (f FirstTimeSetup) renderPRDNameStep() string { Padding(0, 1). Width(modalWidth - 8) - displayName := f.prdName - if displayName == "" { - displayName = " " // Show cursor position - } - content.WriteString(inputStyle.Render(displayName + "█")) + content.WriteString(inputStyle.Render(f.ti.View())) content.WriteString("\n") // Error message @@ -517,7 +553,7 @@ func (f FirstTimeSetup) renderPRDNameStep() string { // Hint content.WriteString("\n") hintStyle := lipgloss.NewStyle().Foreground(MutedColor) - content.WriteString(hintStyle.Render("PRD will be created at: .chief/prds/" + f.prdName + "/")) + content.WriteString(hintStyle.Render("PRD will be created at: .chief/prds/" + f.ti.Value() + "/")) // Footer content.WriteString("\n\n") From 62a85776646e3a592d4c6f57ab7eaeecb1500c6a Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 23 Apr 2026 09:09:06 +0000 Subject: [PATCH 02/11] feat: US-002 - Caret movement keys work inside the PRD name field 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 --- internal/tui/first_time_setup_test.go | 220 ++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 internal/tui/first_time_setup_test.go diff --git a/internal/tui/first_time_setup_test.go b/internal/tui/first_time_setup_test.go new file mode 100644 index 00000000..babf2e1a --- /dev/null +++ b/internal/tui/first_time_setup_test.go @@ -0,0 +1,220 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/cursor" + tea "github.com/charmbracelet/bubbletea" +) + +// newPRDNameSetup returns a FirstTimeSetup positioned on the PRD-name step +// with the textinput pre-populated to value with the cursor at end. +func newPRDNameSetup(t *testing.T, value string) FirstTimeSetup { + t.Helper() + setup := NewFirstTimeSetup(t.TempDir(), false) + setup.ti.SetValue(value) + setup.ti.CursorEnd() + return *setup +} + +func sendKey(t *testing.T, f FirstTimeSetup, msg tea.KeyMsg) FirstTimeSetup { + t.Helper() + model, _ := f.handlePRDNameKeys(msg) + got, ok := model.(FirstTimeSetup) + if !ok { + t.Fatalf("expected FirstTimeSetup model, got %T", model) + } + return got +} + +func TestPRDName_InitialCursorAtEnd(t *testing.T) { + setup := NewFirstTimeSetup(t.TempDir(), false) + if got, want := setup.ti.Value(), "main"; got != want { + t.Fatalf("initial value: got %q, want %q", got, want) + } + if got, want := setup.ti.Position(), len("main"); got != want { + t.Fatalf("initial cursor position: got %d, want %d", got, want) + } +} + +func TestPRDName_LeftArrowMovesCaretLeft(t *testing.T) { + f := newPRDNameSetup(t, "main") // pos=4 + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyLeft}) + if got, want := f.ti.Position(), 3; got != want { + t.Fatalf("after left: got pos %d, want %d", got, want) + } + if got, want := f.ti.Value(), "main"; got != want { + t.Fatalf("value should be unchanged: got %q, want %q", got, want) + } +} + +func TestPRDName_LeftArrowAtPositionZeroIsNoOp(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetCursor(0) + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyLeft}) + if got, want := f.ti.Position(), 0; got != want { + t.Fatalf("left at pos 0 should be no-op: got pos %d, want %d", got, want) + } +} + +func TestPRDName_RightArrowMovesCaretRight(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetCursor(0) + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyRight}) + if got, want := f.ti.Position(), 1; got != want { + t.Fatalf("after right: got pos %d, want %d", got, want) + } +} + +func TestPRDName_RightArrowAtEndIsNoOp(t *testing.T) { + f := newPRDNameSetup(t, "main") // pos=4 (end) + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyRight}) + if got, want := f.ti.Position(), 4; got != want { + t.Fatalf("right at end should be no-op: got pos %d, want %d", got, want) + } +} + +func TestPRDName_HomeJumpsToStart(t *testing.T) { + f := newPRDNameSetup(t, "main") // pos=4 + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyHome}) + if got, want := f.ti.Position(), 0; got != want { + t.Fatalf("after home: got pos %d, want %d", got, want) + } +} + +func TestPRDName_EndJumpsToEnd(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetCursor(0) + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyEnd}) + if got, want := f.ti.Position(), 4; got != want { + t.Fatalf("after end: got pos %d, want %d", got, want) + } +} + +func TestPRDName_CtrlLeftJumpsWordLeft(t *testing.T) { + f := newPRDNameSetup(t, "main") // pos=4, no whitespace → one word + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyCtrlLeft}) + if got, want := f.ti.Position(), 0; got != want { + t.Fatalf("after ctrl+left: got pos %d, want %d", got, want) + } +} + +func TestPRDName_CtrlRightJumpsWordRight(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetCursor(0) + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyCtrlRight}) + if got, want := f.ti.Position(), 4; got != want { + t.Fatalf("after ctrl+right: got pos %d, want %d", got, want) + } +} + +func TestPRDName_TypeInsertsAtCaret(t *testing.T) { + f := newPRDNameSetup(t, "main") // value=main, pos=4 + f.ti.SetCursor(2) // between 'a' and 'i' → "ma|in" + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := f.ti.Value(), "maXin"; got != want { + t.Fatalf("after insert at caret: got %q, want %q", got, want) + } + if got, want := f.ti.Position(), 3; got != want { + t.Fatalf("cursor should advance past inserted rune: got pos %d, want %d", got, want) + } +} + +func TestPRDName_TypeDisallowedRuneIsFiltered(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetCursor(2) + // Mix of allowed ('Y') and disallowed (' ', '!'). + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'Y', ' ', '!'}}) + if got, want := f.ti.Value(), "maYin"; got != want { + t.Fatalf("only allowed runes should be inserted: got %q, want %q", got, want) + } +} + +func TestPRDName_BackspaceDeletesCharBeforeCaret(t *testing.T) { + f := newPRDNameSetup(t, "main") // pos=4 + f.ti.SetCursor(2) // "ma|in" → backspace deletes 'a' + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyBackspace}) + if got, want := f.ti.Value(), "min"; got != want { + t.Fatalf("backspace at caret: got %q, want %q", got, want) + } + if got, want := f.ti.Position(), 1; got != want { + t.Fatalf("cursor should move left after backspace: got pos %d, want %d", got, want) + } +} + +func TestPRDName_BackspaceAtPositionZeroIsNoOp(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetCursor(0) + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyBackspace}) + if got, want := f.ti.Value(), "main"; got != want { + t.Fatalf("backspace at pos 0 should be no-op: got %q, want %q", got, want) + } + if got, want := f.ti.Position(), 0; got != want { + t.Fatalf("cursor at 0 should stay at 0: got pos %d, want %d", got, want) + } +} + +func TestPRDName_ViewRendersVisibleCaret(t *testing.T) { + // The visible caret comes from bubbles' cursor.Model rendering a styled + // block over the character at the cursor position. We can't reliably assert + // on ANSI escapes in tests (lipgloss strips styling when stdout isn't a + // TTY), so we verify the preconditions that make the caret visible at + // runtime: the input is focused, and the cursor is in blink mode (which + // renders a reverse-video block when focused). + f := newPRDNameSetup(t, "main") + if !f.ti.Focused() { + t.Fatal("textinput must be focused for the caret to render") + } + if f.ti.Cursor.Mode() != cursor.CursorBlink { + t.Fatalf("cursor mode must be CursorBlink for a visible caret, got %v", f.ti.Cursor.Mode()) + } + // View() must contain the input value, confirming the field is rendered. + if !strings.Contains(f.ti.View(), "main") { + t.Fatalf("View() should render the input value, got %q", f.ti.View()) + } +} + +func TestPRDName_EnterClearsErrorAndAdvances(t *testing.T) { + f := newPRDNameSetup(t, "main") + model, _ := f.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyEnter}) + got := model.(FirstTimeSetup) + if got.step != StepPostCompletion { + t.Fatalf("enter should advance to post-completion step, got %d", got.step) + } + if got.result.PRDName != "main" { + t.Fatalf("expected result.PRDName=main, got %q", got.result.PRDName) + } +} + +func TestPRDName_EnterRejectsEmptyName(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetValue("") + model, _ := f.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyEnter}) + got := model.(FirstTimeSetup) + if got.step != StepPRDName { + t.Fatalf("empty name should not advance: step=%d", got.step) + } + if got.prdNameError == "" { + t.Fatal("expected an error message for empty name") + } +} + +func TestFilterValidPRDRunes(t *testing.T) { + tests := []struct { + in []rune + want []rune + }{ + {[]rune("abcXYZ"), []rune("abcXYZ")}, + {[]rune("a-b_c"), []rune("a-b_c")}, + {[]rune("01234"), []rune("01234")}, + {[]rune("a b!c"), []rune("abc")}, + {[]rune(""), []rune{}}, + } + for _, tc := range tests { + got := filterValidPRDRunes(tc.in) + if string(got) != string(tc.want) { + t.Errorf("filterValidPRDRunes(%q) = %q, want %q", string(tc.in), string(got), string(tc.want)) + } + } +} From 4a2cd3c3a2655ea3f4280e335e20534ef8f2715f Mon Sep 17 00:00:00 2001 From: Chief Agent Date: Thu, 23 Apr 2026 09:16:32 +0000 Subject: [PATCH 03/11] feat: US-003 - Validation, allowed characters, and submit/back behavior 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 --- internal/tui/first_time_setup.go | 6 +- internal/tui/first_time_setup_test.go | 195 ++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 2 deletions(-) diff --git a/internal/tui/first_time_setup.go b/internal/tui/first_time_setup.go index 92ef3d94..4066250e 100644 --- a/internal/tui/first_time_setup.go +++ b/internal/tui/first_time_setup.go @@ -226,10 +226,12 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // Filter rune input against the allowed character set before forwarding to - // the textinput. Non-rune keys (arrows, backspace, Home/End, Ctrl+arrows, + // the textinput. A bare spacebar press arrives as KeySpace (not KeyRunes), + // so handle both — otherwise spaces would slip past the filter into the + // buffer. Non-rune keys (arrows, backspace, Home/End, Ctrl+arrows, // Alt+Backspace, etc.) pass through unchanged so the bubbles default key // bindings keep working. - if msg.Type == tea.KeyRunes { + if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { msg.Runes = filterValidPRDRunes(msg.Runes) } diff --git a/internal/tui/first_time_setup_test.go b/internal/tui/first_time_setup_test.go index babf2e1a..4fb043e9 100644 --- a/internal/tui/first_time_setup_test.go +++ b/internal/tui/first_time_setup_test.go @@ -210,6 +210,14 @@ func TestFilterValidPRDRunes(t *testing.T) { {[]rune("01234"), []rune("01234")}, {[]rune("a b!c"), []rune("abc")}, {[]rune(""), []rune{}}, + // Multi-byte Unicode runes are dropped — closes the corner case where + // the old byte-length check (`len(msg.String()) == 1`) would + // accidentally drop them on the wrong grounds. + {[]rune("café"), []rune("caf")}, + {[]rune("naïve"), []rune("nave")}, + {[]rune("中文"), []rune{}}, + {[]rune("a日本b"), []rune("ab")}, + {[]rune("emoji-😀-here"), []rune("emoji--here")}, } for _, tc := range tests { got := filterValidPRDRunes(tc.in) @@ -218,3 +226,190 @@ func TestFilterValidPRDRunes(t *testing.T) { } } } + +// TestPRDName_SpaceKeyIsFiltered confirms a real spacebar press (which arrives +// with Type=KeySpace, not KeyRunes) is dropped before reaching the textinput. +// Without explicit handling for KeySpace, a literal space would enter the +// buffer and violate AC1. +func TestPRDName_SpaceKeyIsFiltered(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetCursor(2) + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + if got, want := f.ti.Value(), "main"; got != want { + t.Fatalf("space key should be filtered: got %q, want %q", got, want) + } + if got, want := f.ti.Position(), 2; got != want { + t.Fatalf("filtered key should not advance cursor: got pos %d, want %d", got, want) + } +} + +// TestPRDName_MultiByteRuneIsFiltered verifies multi-byte Unicode runes +// arriving as a single KeyRunes event are silently dropped (AC1). +func TestPRDName_MultiByteRuneIsFiltered(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetCursor(2) + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'é'}}) + if got, want := f.ti.Value(), "main"; got != want { + t.Fatalf("multi-byte rune should be filtered: got %q, want %q", got, want) + } +} + +// TestPRDName_EnterRejectsEmptyNameMessage pins the exact error string from AC2. +func TestPRDName_EnterRejectsEmptyNameMessage(t *testing.T) { + f := newPRDNameSetup(t, "") + model, _ := f.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyEnter}) + got := model.(FirstTimeSetup) + if got.prdNameError != "Name cannot be empty" { + t.Fatalf("expected exact error %q, got %q", "Name cannot be empty", got.prdNameError) + } +} + +// TestPRDName_ErrorClearedOnValueChange verifies AC3: prdNameError is cleared +// whenever the input value changes (here, by typing an allowed rune). +func TestPRDName_ErrorClearedOnValueChange(t *testing.T) { + f := newPRDNameSetup(t, "") + model, _ := f.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyEnter}) + f = model.(FirstTimeSetup) + if f.prdNameError == "" { + t.Fatal("precondition: empty submit should set an error") + } + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if f.prdNameError != "" { + t.Fatalf("error should clear when value changes, got %q", f.prdNameError) + } +} + +// TestPRDName_ErrorPreservedWhenValueUnchanged verifies the error survives a +// keypress that produces no value change (e.g. a fully-filtered space). +func TestPRDName_ErrorPreservedWhenValueUnchanged(t *testing.T) { + f := newPRDNameSetup(t, "") + model, _ := f.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyEnter}) + f = model.(FirstTimeSetup) + wantErr := f.prdNameError + if wantErr == "" { + t.Fatal("precondition: empty submit should set an error") + } + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + if f.prdNameError != wantErr { + t.Fatalf("error should persist when filtered key changes nothing: got %q, want %q", f.prdNameError, wantErr) + } +} + +// TestPRDName_CtrlCCancels verifies AC4: ctrl+c quits and marks the result +// cancelled regardless of the showGitignore branch. +func TestPRDName_CtrlCCancels(t *testing.T) { + for _, showGitignore := range []bool{false, true} { + t.Run("", func(t *testing.T) { + setup := NewFirstTimeSetup(t.TempDir(), showGitignore) + setup.step = StepPRDName + model, cmd := setup.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyCtrlC}) + got := model.(FirstTimeSetup) + if !got.result.Cancelled { + t.Fatal("ctrl+c should set Cancelled=true") + } + if cmd == nil { + t.Fatal("ctrl+c should return a non-nil cmd (tea.Quit)") + } + }) + } +} + +// TestPRDName_EscWithoutGitignoreCancels verifies AC4: when the gitignore step +// was skipped, esc cancels the flow. +func TestPRDName_EscWithoutGitignoreCancels(t *testing.T) { + setup := NewFirstTimeSetup(t.TempDir(), false) + model, cmd := setup.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyEsc}) + got := model.(FirstTimeSetup) + if !got.result.Cancelled { + t.Fatal("esc with no gitignore step should cancel") + } + if cmd == nil { + t.Fatal("esc with no gitignore step should return tea.Quit") + } +} + +// TestPRDName_EscWithGitignoreReturnsToPreviousStep verifies AC4: when the +// gitignore step preceded this one, esc walks back to it (no cancellation), +// and clears any pending error. +func TestPRDName_EscWithGitignoreReturnsToPreviousStep(t *testing.T) { + setup := NewFirstTimeSetup(t.TempDir(), true) + setup.step = StepPRDName + setup.prdNameError = "something" + model, cmd := setup.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyEsc}) + got := model.(FirstTimeSetup) + if got.result.Cancelled { + t.Fatal("esc with gitignore step should not cancel") + } + if got.step != StepGitignore { + t.Fatalf("esc should return to gitignore step, got step=%d", got.step) + } + if got.prdNameError != "" { + t.Fatalf("esc should clear prdNameError, got %q", got.prdNameError) + } + if cmd != nil { + t.Fatal("esc back to gitignore should not return a quit cmd") + } +} + +// TestPRDName_EnterAdvancesAndClearsError verifies AC2 and AC3 together: a +// successful submit clears any prior error and advances to StepPostCompletion. +func TestPRDName_EnterAdvancesAndClearsError(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.prdNameError = "stale error" + model, _ := f.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyEnter}) + got := model.(FirstTimeSetup) + if got.step != StepPostCompletion { + t.Fatalf("expected step=%d (StepPostCompletion), got %d", StepPostCompletion, got.step) + } + if got.result.PRDName != "main" { + t.Fatalf("expected PRDName=main, got %q", got.result.PRDName) + } +} + +// TestPRDName_TextinputWidthMatchesModalContent verifies AC6: the textinput's +// Width tracks the lipgloss content width via prdNameModalWidth - 8, with no +// extra padding subtraction. Resizing should keep them in sync. +func TestPRDName_TextinputWidthMatchesModalContent(t *testing.T) { + setup := NewFirstTimeSetup(t.TempDir(), false) + if got, want := setup.ti.Width, prdNameModalWidth(0)-8; got != want { + t.Fatalf("initial ti.Width: got %d, want %d", got, want) + } + model, _ := setup.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + got := model.(FirstTimeSetup) + if want := prdNameModalWidth(120) - 8; got.ti.Width != want { + t.Fatalf("ti.Width after resize: got %d, want %d", got.ti.Width, want) + } +} + +// TestPRDName_EmptyAndPopulatedFieldHaveSameRenderedWidth verifies AC7: the +// bordered input box keeps the same visual width whether the field is empty +// or contains text. +func TestPRDName_EmptyAndPopulatedFieldHaveSameRenderedWidth(t *testing.T) { + emptySetup := NewFirstTimeSetup(t.TempDir(), false) + emptySetup.width, emptySetup.height = 100, 40 + emptySetup.ti.Width = prdNameModalWidth(100) - 8 + emptySetup.ti.SetValue("") + emptyView := emptySetup.View() + + populatedSetup := NewFirstTimeSetup(t.TempDir(), false) + populatedSetup.width, populatedSetup.height = 100, 40 + populatedSetup.ti.Width = prdNameModalWidth(100) - 8 + populatedSetup.ti.SetValue("main") + populatedView := populatedSetup.View() + + emptyMax := maxLineWidth(emptyView) + populatedMax := maxLineWidth(populatedView) + if emptyMax != populatedMax { + t.Fatalf("rendered max width should match: empty=%d populated=%d", emptyMax, populatedMax) + } +} + +func maxLineWidth(s string) int { + max := 0 + for _, line := range strings.Split(s, "\n") { + if n := len([]rune(line)); n > max { + max = n + } + } + return max +} From 1d3b220a3264d1dec5c45413123fe24851dc6d6d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 22:36:41 +0000 Subject: [PATCH 04/11] feat: US-004 - Paste support with invalid-character filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/tui/first_time_setup_test.go | 118 ++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/internal/tui/first_time_setup_test.go b/internal/tui/first_time_setup_test.go index 4fb043e9..69d75bcd 100644 --- a/internal/tui/first_time_setup_test.go +++ b/internal/tui/first_time_setup_test.go @@ -413,3 +413,121 @@ func maxLineWidth(s string) int { } return max } + +// pasteMsg constructs the KeyMsg bubbletea emits for a bracketed paste: a +// single KeyRunes event with Paste=true carrying the full pasted rune slice. +// See bubbletea v1.3.10 key_sequences.go:109 (detectBracketedPaste). +func pasteMsg(s string) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s), Paste: true} +} + +// TestPRDName_PasteAllValidInsertsAtCaret (US-004 AC1): a string composed +// entirely of allowed characters is inserted at the caret in one step. +func TestPRDName_PasteAllValidInsertsAtCaret(t *testing.T) { + f := newPRDNameSetup(t, "") + f = sendKey(t, f, pasteMsg("my-feature_v2")) + if got, want := f.ti.Value(), "my-feature_v2"; got != want { + t.Fatalf("paste all-valid: got %q, want %q", got, want) + } + if got, want := f.ti.Position(), len("my-feature_v2"); got != want { + t.Fatalf("cursor should advance to end of pasted text: got pos %d, want %d", got, want) + } +} + +// TestPRDName_PasteFiltersInvalidChars (US-004 AC2): invalid characters are +// silently dropped and no error is shown. +func TestPRDName_PasteFiltersInvalidChars(t *testing.T) { + f := newPRDNameSetup(t, "") + f = sendKey(t, f, pasteMsg("my feature/v2!")) + if got, want := f.ti.Value(), "myfeaturev2"; got != want { + t.Fatalf("paste with invalid chars: got %q, want %q", got, want) + } + if f.prdNameError != "" { + t.Fatalf("paste should not set an error, got %q", f.prdNameError) + } +} + +// TestPRDName_PasteTruncatesToMaxLength (US-004 AC3): the field never exceeds +// maxPRDNameLength; existing characters before the caret are preserved. +func TestPRDName_PasteTruncatesToMaxLength(t *testing.T) { + f := newPRDNameSetup(t, "ab") // existing prefix before caret + // Paste more valid characters than would fit. Filter drops no runes, so the + // textinput has to enforce the CharLimit truncation itself. + longPaste := strings.Repeat("x", maxPRDNameLength*2) + f = sendKey(t, f, pasteMsg(longPaste)) + if got := len(f.ti.Value()); got != maxPRDNameLength { + t.Fatalf("value length: got %d, want %d", got, maxPRDNameLength) + } + if got := f.ti.Value(); !strings.HasPrefix(got, "ab") { + t.Fatalf("existing prefix before caret must be preserved: got %q", got) + } +} + +// TestPRDName_PasteAtMiddleCaretSplices (US-004 AC4): pasting with the caret +// mid-buffer splices the filtered text into the middle of the value. +func TestPRDName_PasteAtMiddleCaretSplices(t *testing.T) { + f := newPRDNameSetup(t, "main") // pos=4 + f.ti.SetCursor(2) // "ma|in" + f = sendKey(t, f, pasteMsg("X-Y")) + if got, want := f.ti.Value(), "maX-Yin"; got != want { + t.Fatalf("paste mid-buffer: got %q, want %q", got, want) + } + if got, want := f.ti.Position(), 5; got != want { + t.Fatalf("cursor should sit right after the pasted text: got pos %d, want %d", got, want) + } +} + +// TestPRDName_PasteAtMiddleCaretSplicesWithFiltering combines AC2 and AC4: an +// in-middle paste with invalid chars splices only the valid subset. +func TestPRDName_PasteAtMiddleCaretSplicesWithFiltering(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.ti.SetCursor(2) + f = sendKey(t, f, pasteMsg("X Y/Z")) + if got, want := f.ti.Value(), "maXYZin"; got != want { + t.Fatalf("filtered paste mid-buffer: got %q, want %q", got, want) + } +} + +// TestPRDName_PasteClearsError (US-004 AC5): a paste that changes the value +// clears prdNameError. +func TestPRDName_PasteClearsError(t *testing.T) { + f := newPRDNameSetup(t, "") + model, _ := f.handlePRDNameKeys(tea.KeyMsg{Type: tea.KeyEnter}) + f = model.(FirstTimeSetup) + if f.prdNameError == "" { + t.Fatal("precondition: empty submit should set an error") + } + f = sendKey(t, f, pasteMsg("feature")) + if f.prdNameError != "" { + t.Fatalf("paste should clear prdNameError, got %q", f.prdNameError) + } + if got, want := f.ti.Value(), "feature"; got != want { + t.Fatalf("paste value: got %q, want %q", got, want) + } +} + +// TestPRDName_PasteAllInvalidIsNoOp verifies that a paste containing only +// invalid characters leaves the value unchanged (and therefore does not clear +// a standing error — sister assertion to AC5's "changes the value" wording). +func TestPRDName_PasteAllInvalidIsNoOp(t *testing.T) { + f := newPRDNameSetup(t, "main") + f.prdNameError = "sticky" + f = sendKey(t, f, pasteMsg("! @ # $")) + if got, want := f.ti.Value(), "main"; got != want { + t.Fatalf("all-invalid paste should not change value: got %q, want %q", got, want) + } + if f.prdNameError != "sticky" { + t.Fatalf("error should persist when paste changes nothing: got %q", f.prdNameError) + } +} + +// TestPRDName_PasteWithoutBracketedFlagAlsoFiltered verifies the same filter +// path handles a multi-rune KeyRunes event that lacks Paste=true (the +// fallback path when bracketed paste is disabled in the terminal). +func TestPRDName_PasteWithoutBracketedFlagAlsoFiltered(t *testing.T) { + f := newPRDNameSetup(t, "") + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ab/cd!")}) + if got, want := f.ti.Value(), "abcd"; got != want { + t.Fatalf("non-bracketed multi-rune paste: got %q, want %q", got, want) + } +} From 63bcc83ccc1dce7dca5b9acf5d9682622cd33ca8 Mon Sep 17 00:00:00 2001 From: "Claude (Chief)" Date: Thu, 23 Apr 2026 22:39:57 +0000 Subject: [PATCH 05/11] feat: US-005 - Configurable maximum length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/tui/first_time_setup_test.go | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/tui/first_time_setup_test.go b/internal/tui/first_time_setup_test.go index 69d75bcd..f5439a92 100644 --- a/internal/tui/first_time_setup_test.go +++ b/internal/tui/first_time_setup_test.go @@ -531,3 +531,35 @@ func TestPRDName_PasteWithoutBracketedFlagAlsoFiltered(t *testing.T) { t.Fatalf("non-bracketed multi-rune paste: got %q, want %q", got, want) } } + +// TestPRDName_CharLimitMatchesConstant (US-005 AC2/AC5): the textinput's +// CharLimit is wired from maxPRDNameLength and does not drift to a hard-coded +// value. Changing the constant must be the only change needed to adjust the +// limit — this test fails loudly if a future refactor ever hard-codes a length. +func TestPRDName_CharLimitMatchesConstant(t *testing.T) { + setup := NewFirstTimeSetup(t.TempDir(), false) + if got, want := setup.ti.CharLimit, maxPRDNameLength; got != want { + t.Fatalf("ti.CharLimit: got %d, want %d (should track maxPRDNameLength)", got, want) + } +} + +// TestPRDName_TypingAtMaxLengthIsNoOp (US-005 AC4): once the field is at the +// maximum length, typing a further allowed character is silently dropped — +// value unchanged, cursor unchanged, no error shown. +func TestPRDName_TypingAtMaxLengthIsNoOp(t *testing.T) { + full := strings.Repeat("a", maxPRDNameLength) + f := newPRDNameSetup(t, full) + if got := len(f.ti.Value()); got != maxPRDNameLength { + t.Fatalf("precondition: value should be at max length, got %d", got) + } + f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := f.ti.Value(), full; got != want { + t.Fatalf("typing at max length should not change value: got %q, want %q", got, want) + } + if got, want := f.ti.Position(), maxPRDNameLength; got != want { + t.Fatalf("cursor should not advance past max length: got pos %d, want %d", got, want) + } + if f.prdNameError != "" { + t.Fatalf("typing at max length must be silent (no error), got %q", f.prdNameError) + } +} From e5f8d550c0b011f98907758089dd5706a3c5e608 Mon Sep 17 00:00:00 2001 From: Chief Agent Date: Thu, 23 Apr 2026 22:49:38 +0000 Subject: [PATCH 06/11] feat: US-006 - Automated test for caret editing, paste, and max length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/tui/first_time_setup.go | 50 +++++++ internal/tui/first_time_setup_test.go | 194 ++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) diff --git a/internal/tui/first_time_setup.go b/internal/tui/first_time_setup.go index 4066250e..429a139a 100644 --- a/internal/tui/first_time_setup.go +++ b/internal/tui/first_time_setup.go @@ -223,6 +223,18 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { f.result.PRDName = name f.step = StepPostCompletion return f, nil + + // Override bubbles' whitespace-only word jump so `-` and `_` act as word + // separators — useful for PRD names like `foo-bar` where bubbles' default + // would treat the whole identifier as one word and collapse Ctrl+Left/Right + // to Home/End. + case "ctrl+left", "alt+left", "alt+b": + f.ti.SetCursor(prdNameWordBackward([]rune(f.ti.Value()), f.ti.Position())) + return f, nil + + case "ctrl+right", "alt+right", "alt+f": + f.ti.SetCursor(prdNameWordForward([]rune(f.ti.Value()), f.ti.Position())) + return f, nil } // Filter rune input against the allowed character set before forwarding to @@ -244,6 +256,44 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return f, cmd } +// prdNameWordBackward returns the caret position after a word-jump-left from +// pos, treating `-` and `_` as word separators. Mirrors bubbles' +// wordBackward structure (skip separators, then skip non-separators) so +// behavior is predictable next to the built-in key bindings. +func prdNameWordBackward(value []rune, pos int) int { + if pos <= 0 || len(value) == 0 { + return 0 + } + i := pos - 1 + for i >= 0 && isPRDNameWordSeparator(value[i]) { + i-- + } + for i >= 0 && !isPRDNameWordSeparator(value[i]) { + i-- + } + return i + 1 +} + +// prdNameWordForward is the forward counterpart of prdNameWordBackward. +func prdNameWordForward(value []rune, pos int) int { + n := len(value) + if pos >= n { + return n + } + i := pos + for i < n && isPRDNameWordSeparator(value[i]) { + i++ + } + for i < n && !isPRDNameWordSeparator(value[i]) { + i++ + } + return i +} + +func isPRDNameWordSeparator(r rune) bool { + return r == '-' || r == '_' +} + // filterValidPRDRunes drops any rune outside the allowed PRD-name character // set ([a-zA-Z0-9_-]). Returns a new slice so the caller can safely forward // the filtered KeyMsg to the textinput. diff --git a/internal/tui/first_time_setup_test.go b/internal/tui/first_time_setup_test.go index f5439a92..4dc7df66 100644 --- a/internal/tui/first_time_setup_test.go +++ b/internal/tui/first_time_setup_test.go @@ -1,13 +1,29 @@ package tui import ( + "reflect" "strings" "testing" "github.com/charmbracelet/bubbles/cursor" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) +// updateKey drives the public Update(...) dispatch with the given KeyMsg and +// returns the resulting FirstTimeSetup. Use for US-006 tests, where the AC +// requires tests to drive the model via Update rather than calling the +// step-level handlers directly. +func updateKey(t *testing.T, f FirstTimeSetup, msg tea.KeyMsg) FirstTimeSetup { + t.Helper() + model, _ := f.Update(msg) + got, ok := model.(FirstTimeSetup) + if !ok { + t.Fatalf("expected FirstTimeSetup model, got %T", model) + } + return got +} + // newPRDNameSetup returns a FirstTimeSetup positioned on the PRD-name step // with the textinput pre-populated to value with the cursor at end. func newPRDNameSetup(t *testing.T, value string) FirstTimeSetup { @@ -563,3 +579,181 @@ func TestPRDName_TypingAtMaxLengthIsNoOp(t *testing.T) { t.Fatalf("typing at max length must be silent (no error), got %q", f.prdNameError) } } + +// ----------------------------------------------------------------------------- +// US-006: end-to-end regression suite driven through Update(...) +// ----------------------------------------------------------------------------- + +// TestUS006_LeftTwiceInsert: "foo" → Left, Left → type 'X' yields "fXoo". +func TestUS006_LeftTwiceInsert(t *testing.T) { + f := newPRDNameSetup(t, "foo") // pos=3 + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyLeft}) + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyLeft}) + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := f.ti.Value(), "fXoo"; got != want { + t.Fatalf("left×2 + 'X': got %q, want %q", got, want) + } +} + +// TestUS006_HomeInsert: "foo" → Home → type 'X' yields "Xfoo". +func TestUS006_HomeInsert(t *testing.T) { + f := newPRDNameSetup(t, "foo") + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyHome}) + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := f.ti.Value(), "Xfoo"; got != want { + t.Fatalf("home + 'X': got %q, want %q", got, want) + } +} + +// TestUS006_CtrlLeftStopsAtHyphen: Ctrl+Left on "foo-bar" lands just after the +// hyphen so 'X' yields "foo-Xbar". Exercises the parent-level intercept that +// treats `-` and `_` as word separators. +func TestUS006_CtrlLeftStopsAtHyphen(t *testing.T) { + f := newPRDNameSetup(t, "foo-bar") // pos=7 + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyCtrlLeft}) + if got, want := f.ti.Position(), 4; got != want { + t.Fatalf("ctrl+left on 'foo-bar' cursor: got pos %d, want %d", got, want) + } + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := f.ti.Value(), "foo-Xbar"; got != want { + t.Fatalf("ctrl+left + 'X' on 'foo-bar': got %q, want %q", got, want) + } +} + +// TestUS006_InvalidAsciiSilentlyRejected: '!' is dropped; value and error +// state are unchanged. +func TestUS006_InvalidAsciiSilentlyRejected(t *testing.T) { + f := newPRDNameSetup(t, "main") + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) + if got, want := f.ti.Value(), "main"; got != want { + t.Fatalf("invalid ascii: got %q, want %q", got, want) + } + if f.prdNameError != "" { + t.Fatalf("invalid ascii must not set an error, got %q", f.prdNameError) + } +} + +// TestUS006_InvalidMultiByteRunesSilentlyRejected: é, 中, 🦄 are all rejected. +// Locks in the by-design ASCII-only behavior from US-003 AC1. +func TestUS006_InvalidMultiByteRunesSilentlyRejected(t *testing.T) { + for _, r := range []rune{'é', '中', '🦄'} { + f := newPRDNameSetup(t, "main") + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + if got, want := f.ti.Value(), "main"; got != want { + t.Fatalf("multi-byte rune %q: got %q, want %q", r, got, want) + } + } +} + +// TestUS006_PasteMyFeatureV2: pasting "my feature/v2!" into an empty field +// yields "myfeaturev2". +func TestUS006_PasteMyFeatureV2(t *testing.T) { + f := newPRDNameSetup(t, "") + f = updateKey(t, f, pasteMsg("my feature/v2!")) + if got, want := f.ti.Value(), "myfeaturev2"; got != want { + t.Fatalf("paste 'my feature/v2!': got %q, want %q", got, want) + } +} + +// TestUS006_PasteTripleMaxLengthTruncates: a paste of maxPRDNameLength*3 +// valid characters is truncated to exactly maxPRDNameLength. Asserts against +// the constant, not a literal, so the test tracks the limit if it's tuned. +func TestUS006_PasteTripleMaxLengthTruncates(t *testing.T) { + f := newPRDNameSetup(t, "") + f = updateKey(t, f, pasteMsg(strings.Repeat("a", maxPRDNameLength*3))) + if got := len(f.ti.Value()); got != maxPRDNameLength { + t.Fatalf("paste length: got %d, want %d", got, maxPRDNameLength) + } +} + +// TestUS006_TypingPastMaxLengthIsSilentNoOp: typing at max length keeps the +// value at max and shows no error. +func TestUS006_TypingPastMaxLengthIsSilentNoOp(t *testing.T) { + full := strings.Repeat("a", maxPRDNameLength) + f := newPRDNameSetup(t, full) + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'b'}}) + if got := len(f.ti.Value()); got != maxPRDNameLength { + t.Fatalf("value length after typing past max: got %d, want %d", got, maxPRDNameLength) + } + if f.prdNameError != "" { + t.Fatalf("typing past max must not show an error, got %q", f.prdNameError) + } +} + +// TestUS006_EnterOnEmptySetsErrorAndStays: Enter on empty value sets the exact +// error "Name cannot be empty" and does not advance past StepPRDName. +func TestUS006_EnterOnEmptySetsErrorAndStays(t *testing.T) { + f := newPRDNameSetup(t, "") + f = updateKey(t, f, tea.KeyMsg{Type: tea.KeyEnter}) + if f.prdNameError != "Name cannot be empty" { + t.Fatalf("prdNameError: got %q, want %q", f.prdNameError, "Name cannot be empty") + } + if f.step != StepPRDName { + t.Fatalf("empty submit should not advance past StepPRDName: got step=%d", f.step) + } +} + +// TestUS006_GitignoreToPRDNameBlinkCmd: with showGitignore=true, driving +// confirmGitignore() returns a non-nil cmd whose invocation produces the +// message that textinput.Blink produces in this bubbles version. Locks in the +// US-001 acceptance for blink wiring on the gitignore→PRDName transition. +func TestUS006_GitignoreToPRDNameBlinkCmd(t *testing.T) { + setup := NewFirstTimeSetup(t.TempDir(), true) + setup.gitignoreSelected = 1 // "No" — avoid touching the temp dir's .gitignore + model, cmd := setup.confirmGitignore() + got, ok := model.(FirstTimeSetup) + if !ok { + t.Fatalf("expected FirstTimeSetup model, got %T", model) + } + if got.step != StepPRDName { + t.Fatalf("confirmGitignore should transition to StepPRDName, got step=%d", got.step) + } + if cmd == nil { + t.Fatal("confirmGitignore should return a non-nil tea.Cmd") + } + msg := cmd() + if msg == nil { + t.Fatal("invoked cmd should produce a non-nil tea.Msg") + } + wantType := reflect.TypeOf(textinput.Blink()) + if gotType := reflect.TypeOf(msg); gotType != wantType { + t.Fatalf("cmd should produce %v, got %v", wantType, gotType) + } +} + +// TestUS006_InitBatchesAltScreenAndBlink: with showGitignore=false, Init() +// returns a batch that, when invoked, yields both tea.EnterAltScreen's +// message and textinput.Blink's message. Locks in US-001 AC for Init batching +// in the no-gitignore flow. +func TestUS006_InitBatchesAltScreenAndBlink(t *testing.T) { + setup := NewFirstTimeSetup(t.TempDir(), false) + cmd := setup.Init() + if cmd == nil { + t.Fatal("Init() should return a non-nil cmd when gitignore is skipped") + } + msg := cmd() + batch, ok := msg.(tea.BatchMsg) + if !ok { + t.Fatalf("Init() cmd should produce a tea.BatchMsg, got %T", msg) + } + wantAltScreen := reflect.TypeOf(tea.EnterAltScreen()) + wantBlink := reflect.TypeOf(textinput.Blink()) + var sawAltScreen, sawBlink bool + for _, c := range batch { + if c == nil { + continue + } + switch reflect.TypeOf(c()) { + case wantAltScreen: + sawAltScreen = true + case wantBlink: + sawBlink = true + } + } + if !sawAltScreen { + t.Error("batch should include the alt-screen message") + } + if !sawBlink { + t.Error("batch should include the blink message") + } +} From b8feb65c1102626e378d7c2d9a0b6a7c61415805 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 00:47:00 +0000 Subject: [PATCH 07/11] feat: US-007 - Adopt `bubbles/textinput` for the PRDPicker new-PRD-name input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/tui/app.go | 18 ++---- internal/tui/first_time_setup.go | 58 +----------------- internal/tui/first_time_setup_test.go | 4 +- internal/tui/input_filters.go | 63 ++++++++++++++++++++ internal/tui/picker.go | 84 ++++++++++++++++++--------- 5 files changed, 130 insertions(+), 97 deletions(-) create mode 100644 internal/tui/input_filters.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 37694fea..84bb032e 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -605,8 +605,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.viewMode == ViewDashboard || a.viewMode == ViewLog || a.viewMode == ViewDiff { a.picker.Refresh() a.picker.SetSize(a.width, a.height) - a.picker.StartInputMode() + cmd := a.picker.StartInputMode() a.viewMode = ViewPicker + return a, cmd } return a, nil @@ -1851,16 +1852,9 @@ func (a App) handlePickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } a.picker.CancelInputMode() return a, nil - case "backspace": - a.picker.DeleteInputChar() - return a, nil - default: - // Handle character input - if len(msg.String()) == 1 { - a.picker.AddInputChar(rune(msg.String()[0])) - } - return a, nil } + cmd := a.picker.UpdateInput(msg) + return a, cmd } // Dismiss clean result on any key @@ -1904,8 +1898,8 @@ func (a App) handlePickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return a, nil case "n": - a.picker.StartInputMode() - return a, nil + cmd := a.picker.StartInputMode() + return a, cmd case "e": // Edit the selected PRD - launch interactive Claude session entry := a.picker.GetSelectedEntry() diff --git a/internal/tui/first_time_setup.go b/internal/tui/first_time_setup.go index 429a139a..6f764963 100644 --- a/internal/tui/first_time_setup.go +++ b/internal/tui/first_time_setup.go @@ -229,11 +229,11 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // would treat the whole identifier as one word and collapse Ctrl+Left/Right // to Home/End. case "ctrl+left", "alt+left", "alt+b": - f.ti.SetCursor(prdNameWordBackward([]rune(f.ti.Value()), f.ti.Position())) + f.ti.SetCursor(wordBackward([]rune(f.ti.Value()), f.ti.Position(), prdNameSeparators)) return f, nil case "ctrl+right", "alt+right", "alt+f": - f.ti.SetCursor(prdNameWordForward([]rune(f.ti.Value()), f.ti.Position())) + f.ti.SetCursor(wordForward([]rune(f.ti.Value()), f.ti.Position(), prdNameSeparators)) return f, nil } @@ -244,7 +244,7 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Alt+Backspace, etc.) pass through unchanged so the bubbles default key // bindings keep working. if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { - msg.Runes = filterValidPRDRunes(msg.Runes) + msg.Runes = filterPRDNameRunes(msg.Runes) } before := f.ti.Value() @@ -256,58 +256,6 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return f, cmd } -// prdNameWordBackward returns the caret position after a word-jump-left from -// pos, treating `-` and `_` as word separators. Mirrors bubbles' -// wordBackward structure (skip separators, then skip non-separators) so -// behavior is predictable next to the built-in key bindings. -func prdNameWordBackward(value []rune, pos int) int { - if pos <= 0 || len(value) == 0 { - return 0 - } - i := pos - 1 - for i >= 0 && isPRDNameWordSeparator(value[i]) { - i-- - } - for i >= 0 && !isPRDNameWordSeparator(value[i]) { - i-- - } - return i + 1 -} - -// prdNameWordForward is the forward counterpart of prdNameWordBackward. -func prdNameWordForward(value []rune, pos int) int { - n := len(value) - if pos >= n { - return n - } - i := pos - for i < n && isPRDNameWordSeparator(value[i]) { - i++ - } - for i < n && !isPRDNameWordSeparator(value[i]) { - i++ - } - return i -} - -func isPRDNameWordSeparator(r rune) bool { - return r == '-' || r == '_' -} - -// filterValidPRDRunes drops any rune outside the allowed PRD-name character -// set ([a-zA-Z0-9_-]). Returns a new slice so the caller can safely forward -// the filtered KeyMsg to the textinput. -func filterValidPRDRunes(runes []rune) []rune { - filtered := make([]rune, 0, len(runes)) - for _, r := range runes { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '-' || r == '_' { - filtered = append(filtered, r) - } - } - return filtered -} - // isValidPRDName checks if a name is valid for a PRD. func isValidPRDName(name string) bool { validName := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) diff --git a/internal/tui/first_time_setup_test.go b/internal/tui/first_time_setup_test.go index 4dc7df66..e27c4962 100644 --- a/internal/tui/first_time_setup_test.go +++ b/internal/tui/first_time_setup_test.go @@ -236,9 +236,9 @@ func TestFilterValidPRDRunes(t *testing.T) { {[]rune("emoji-😀-here"), []rune("emoji--here")}, } for _, tc := range tests { - got := filterValidPRDRunes(tc.in) + got := filterPRDNameRunes(tc.in) if string(got) != string(tc.want) { - t.Errorf("filterValidPRDRunes(%q) = %q, want %q", string(tc.in), string(got), string(tc.want)) + t.Errorf("filterPRDNameRunes(%q) = %q, want %q", string(tc.in), string(got), string(tc.want)) } } } diff --git a/internal/tui/input_filters.go b/internal/tui/input_filters.go new file mode 100644 index 00000000..66495517 --- /dev/null +++ b/internal/tui/input_filters.go @@ -0,0 +1,63 @@ +package tui + +// prdNameSeparators are the word-separator runes used by PRD-name editors +// (both FirstTimeSetup's StepPRDName and PRDPicker's new-PRD-name input) for +// Ctrl+Left/Right word jumps. Defined once so the two widgets can't drift. +var prdNameSeparators = []rune{'-', '_'} + +// filterPRDNameRunes drops any rune outside the allowed PRD-name character +// set ([a-zA-Z0-9_-]). Returns a new slice so the caller can safely forward +// the filtered KeyMsg to the textinput. +func filterPRDNameRunes(runes []rune) []rune { + filtered := make([]rune, 0, len(runes)) + for _, r := range runes { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' { + filtered = append(filtered, r) + } + } + return filtered +} + +// wordBackward returns the caret position after a word-jump-left from pos, +// treating any rune in seps as a word separator. Mirrors bubbles' +// wordBackward structure (skip separators, then skip non-separators) so +// behavior is predictable next to the built-in key bindings. +func wordBackward(value []rune, pos int, seps []rune) int { + if pos <= 0 || len(value) == 0 { + return 0 + } + i := pos - 1 + for i >= 0 && isSeparator(value[i], seps) { + i-- + } + for i >= 0 && !isSeparator(value[i], seps) { + i-- + } + return i + 1 +} + +// wordForward is the forward counterpart of wordBackward. +func wordForward(value []rune, pos int, seps []rune) int { + n := len(value) + if pos >= n { + return n + } + i := pos + for i < n && isSeparator(value[i], seps) { + i++ + } + for i < n && !isSeparator(value[i], seps) { + i++ + } + return i +} + +func isSeparator(r rune, seps []rune) bool { + for _, s := range seps { + if r == s { + return true + } + } + return false +} diff --git a/internal/tui/picker.go b/internal/tui/picker.go index dd1e4032..9496c6db 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/minicodemonkey/chief/internal/git" "github.com/minicodemonkey/chief/internal/loop" @@ -68,7 +70,7 @@ type PRDPicker struct { basePath string // Base path where .chief/prds/ is located currentPRD string // Name of the currently active PRD inputMode bool // Whether we're in input mode for new PRD name - inputValue string // The current input value for new PRD name + ti textinput.Model // Bubbles textinput for the new-PRD-name field manager *loop.Manager // Reference to the loop manager for status updates mergeResult *MergeResult // Result of the last merge operation (nil = none) cleanConfirmation *CleanConfirmation // Active clean confirmation dialog (nil = none) @@ -77,19 +79,37 @@ type PRDPicker struct { // NewPRDPicker creates a new PRD picker. func NewPRDPicker(basePath string, currentPRDName string, manager *loop.Manager) *PRDPicker { + ti := textinput.New() + ti.Prompt = "" + ti.Placeholder = "(type a name...)" + ti.CharLimit = maxPRDNameLength + ti.Width = pickerInputWidth(0) + p := &PRDPicker{ entries: make([]PRDEntry, 0), selectedIndex: 0, basePath: basePath, currentPRD: currentPRDName, inputMode: false, - inputValue: "", + ti: ti, manager: manager, } p.Refresh() return p } +// pickerInputWidth returns the textinput width for the new-PRD-name field based +// on the current modal width. Kept in one place so the textinput stays in +// parity with the surrounding lipgloss box (see renderInputMode). +func pickerInputWidth(terminalWidth int) int { + modalWidth := min(60, terminalWidth-10) + if modalWidth < 30 { + modalWidth = 30 + } + // matches (modalWidth - 4) outer - 4 inner border/padding in renderInputMode + return modalWidth - 8 +} + // SetManager sets the loop manager reference. func (p *PRDPicker) SetManager(manager *loop.Manager) { p.manager = manager @@ -236,6 +256,7 @@ func (p *PRDPicker) loadPRDEntry(name, prdPath string) PRDEntry { func (p *PRDPicker) SetSize(width, height int) { p.width = width p.height = height + p.ti.Width = pickerInputWidth(width) } // MoveUp moves the selection up. @@ -276,37 +297,52 @@ func (p *PRDPicker) IsInputMode() bool { return p.inputMode } -// StartInputMode enters input mode for creating a new PRD. -func (p *PRDPicker) StartInputMode() { +// StartInputMode enters input mode for creating a new PRD. Returns the blink +// cmd the containing tea program must propagate so the textinput's caret +// renders visibly (FR-10). +func (p *PRDPicker) StartInputMode() tea.Cmd { p.inputMode = true - p.inputValue = "" + p.ti.SetValue("") + p.ti.Focus() + return textinput.Blink } // CancelInputMode exits input mode without creating a PRD. func (p *PRDPicker) CancelInputMode() { p.inputMode = false - p.inputValue = "" + p.ti.SetValue("") + p.ti.Blur() } // GetInputValue returns the current input value. func (p *PRDPicker) GetInputValue() string { - return p.inputValue + return p.ti.Value() } -// AddInputChar adds a character to the input. -func (p *PRDPicker) AddInputChar(ch rune) { - // Only allow valid directory name characters - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' { - p.inputValue += string(ch) +// UpdateInput forwards a tea.KeyMsg to the new-PRD-name textinput, applying +// the PRD-name charset filter to rune input and overriding Ctrl+Left/Right +// (and their Alt-variants) to treat `-` and `_` as word separators. Non-rune +// keys (arrows, backspace, Home/End, etc.) pass through unchanged. +// +// Callers MUST have already matched input-mode control keys (esc, enter) +// before forwarding here (see FR-9). +func (p *PRDPicker) UpdateInput(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "ctrl+left", "alt+left", "alt+b": + p.ti.SetCursor(wordBackward([]rune(p.ti.Value()), p.ti.Position(), prdNameSeparators)) + return nil + case "ctrl+right", "alt+right", "alt+f": + p.ti.SetCursor(wordForward([]rune(p.ti.Value()), p.ti.Position(), prdNameSeparators)) + return nil } -} -// DeleteInputChar removes the last character from the input. -func (p *PRDPicker) DeleteInputChar() { - if len(p.inputValue) > 0 { - p.inputValue = p.inputValue[:len(p.inputValue)-1] + if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { + msg.Runes = filterPRDNameRunes(msg.Runes) } + + var cmd tea.Cmd + p.ti, cmd = p.ti.Update(msg) + return cmd } // SetCurrentPRD sets the current PRD name for highlighting. @@ -714,22 +750,14 @@ func (p *PRDPicker) renderInputMode(width int) string { content.WriteString(labelStyle.Render("New PRD name:")) content.WriteString("\n\n") - // Input field + // Input field — bubbles' textinput renders its own placeholder and caret. inputStyle := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(PrimaryColor). Padding(0, 1). Width(width - 4) - inputValue := p.inputValue - if inputValue == "" { - inputValue = lipgloss.NewStyle().Foreground(MutedColor).Render("(type a name...)") - } - // Add cursor - cursorStyle := lipgloss.NewStyle().Foreground(PrimaryColor).Blink(true) - inputValue += cursorStyle.Render("▌") - - content.WriteString(inputStyle.Render(inputValue)) + content.WriteString(inputStyle.Render(p.ti.View())) content.WriteString("\n\n") hintStyle := lipgloss.NewStyle().Foreground(MutedColor) From c7df675587b0911c4598057d713bbfa1e3074c65 Mon Sep 17 00:00:00 2001 From: Chief Agent Date: Fri, 24 Apr 2026 08:53:29 +0000 Subject: [PATCH 08/11] feat: US-008 - Adopt `bubbles/textinput` for the BranchWarning branch-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/` 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) --- internal/tui/app.go | 14 ++----- internal/tui/branch_warning.go | 65 +++++++++++++++++++---------- internal/tui/branch_warning_test.go | 26 ++++++------ internal/tui/input_filters.go | 18 ++++++++ 4 files changed, 80 insertions(+), 43 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 84bb032e..b199488b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1102,16 +1102,9 @@ func (a App) handleBranchWarningKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Confirm edit a.branchWarning.CancelEditMode() return a, nil - case "backspace": - a.branchWarning.DeleteInputChar() - return a, nil - default: - // Add character to branch name - if len(msg.String()) == 1 { - a.branchWarning.AddInputChar(rune(msg.String()[0])) - } - return a, nil } + cmd := a.branchWarning.UpdateInput(msg) + return a, cmd } switch msg.String() { @@ -1134,7 +1127,8 @@ func (a App) handleBranchWarningKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Start editing branch name if on an option that involves a branch opt := a.branchWarning.GetSelectedOption() if opt == BranchOptionCreateWorktree || opt == BranchOptionCreateBranch { - a.branchWarning.StartEditMode() + cmd := a.branchWarning.StartEditMode() + return a, cmd } return a, nil diff --git a/internal/tui/branch_warning.go b/internal/tui/branch_warning.go index 106ca0cc..c9871173 100644 --- a/internal/tui/branch_warning.go +++ b/internal/tui/branch_warning.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -29,6 +31,8 @@ const ( DialogNoConflicts ) +const maxBranchNameLength = 255 + // dialogOption represents a single option in the dialog. type dialogOption struct { label string // Display label @@ -45,16 +49,21 @@ type BranchWarning struct { prdName string worktreePath string // Relative worktree path (e.g., ".chief/worktrees/auth/") selectedIndex int - editMode bool // Whether we're editing the branch name - branchName string // The current branch name (editable) + editMode bool // Whether we're editing the branch name + ti textinput.Model context DialogContext options []dialogOption } // NewBranchWarning creates a new branch warning dialog. func NewBranchWarning() *BranchWarning { + ti := textinput.New() + ti.Prompt = "" + ti.CharLimit = maxBranchNameLength + return &BranchWarning{ selectedIndex: 0, + ti: ti, } } @@ -68,7 +77,8 @@ func (b *BranchWarning) SetSize(width, height int) { func (b *BranchWarning) SetContext(currentBranch, prdName, worktreePath string) { b.currentBranch = currentBranch b.prdName = prdName - b.branchName = fmt.Sprintf("chief/%s", prdName) + b.ti.SetValue(fmt.Sprintf("chief/%s", prdName)) + b.ti.CursorEnd() b.worktreePath = worktreePath } @@ -145,7 +155,7 @@ func (b *BranchWarning) buildOptions() { // GetSuggestedBranch returns the branch name (may be edited by user). func (b *BranchWarning) GetSuggestedBranch() string { - return b.branchName + return b.ti.Value() } // MoveUp moves selection up. @@ -179,7 +189,9 @@ func (b *BranchWarning) GetDialogContext() DialogContext { func (b *BranchWarning) Reset() { b.selectedIndex = 0 b.editMode = false - b.branchName = fmt.Sprintf("chief/%s", b.prdName) + b.ti.Blur() + b.ti.SetValue(fmt.Sprintf("chief/%s", b.prdName)) + b.ti.CursorEnd() } // IsEditMode returns true if the branch name is being edited. @@ -188,29 +200,42 @@ func (b *BranchWarning) IsEditMode() bool { } // StartEditMode enters edit mode for the branch name. -func (b *BranchWarning) StartEditMode() { +func (b *BranchWarning) StartEditMode() tea.Cmd { b.editMode = true + b.ti.Focus() + b.ti.CursorEnd() + return textinput.Blink } // CancelEditMode exits edit mode. func (b *BranchWarning) CancelEditMode() { b.editMode = false + b.ti.Blur() } -// AddInputChar adds a character to the branch name. -func (b *BranchWarning) AddInputChar(ch rune) { - // Only allow valid git branch name characters - if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || - (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '/' { - b.branchName += string(ch) +// UpdateInput forwards a tea.KeyMsg to the branch-name textinput, applying the +// branch-name charset filter to rune input and overriding Ctrl+Left/Right (and +// their Alt-variants) to treat '-', '_', and '/' as word separators. +// +// Callers MUST have already matched edit-mode control keys (esc, enter) before +// forwarding here (see FR-9). +func (b *BranchWarning) UpdateInput(msg tea.KeyMsg) tea.Cmd { + switch msg.String() { + case "ctrl+left", "alt+left", "alt+b": + b.ti.SetCursor(wordBackward([]rune(b.ti.Value()), b.ti.Position(), branchNameSeparators)) + return nil + case "ctrl+right", "alt+right", "alt+f": + b.ti.SetCursor(wordForward([]rune(b.ti.Value()), b.ti.Position(), branchNameSeparators)) + return nil } -} -// DeleteInputChar removes the last character from the branch name. -func (b *BranchWarning) DeleteInputChar() { - if len(b.branchName) > 0 { - b.branchName = b.branchName[:len(b.branchName)-1] + if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { + msg.Runes = filterBranchNameRunes(msg.Runes) } + + var cmd tea.Cmd + b.ti, cmd = b.ti.Update(msg) + return cmd } // selectedOptionHasBranch returns true if the currently selected option involves branch creation. @@ -325,12 +350,10 @@ func (b *BranchWarning) renderBranchName(content *strings.Builder) { inputStyle := lipgloss.NewStyle(). Foreground(TextBrightColor). Background(lipgloss.Color("237")) - cursorStyle := lipgloss.NewStyle().Foreground(PrimaryColor).Blink(true) - content.WriteString(inputStyle.Render(b.branchName)) - content.WriteString(cursorStyle.Render("▌")) + content.WriteString(inputStyle.Render(b.ti.View())) content.WriteString("\n\n") } else { - content.WriteString(branchLabelStyle.Render(fmt.Sprintf("Branch: %s", b.branchName))) + content.WriteString(branchLabelStyle.Render(fmt.Sprintf("Branch: %s", b.ti.Value()))) content.WriteString("\n\n") } } diff --git a/internal/tui/branch_warning_test.go b/internal/tui/branch_warning_test.go index c637c477..d1ad561a 100644 --- a/internal/tui/branch_warning_test.go +++ b/internal/tui/branch_warning_test.go @@ -2,6 +2,8 @@ package tui import ( "testing" + + tea "github.com/charmbracelet/bubbletea" ) func TestBranchWarningProtectedBranch(t *testing.T) { @@ -174,23 +176,23 @@ func TestBranchWarningBranchEdit(t *testing.T) { } // Delete and add chars - bw.DeleteInputChar() - bw.DeleteInputChar() - bw.DeleteInputChar() - bw.DeleteInputChar() - bw.AddInputChar('m') - bw.AddInputChar('y') - bw.AddInputChar('-') - bw.AddInputChar('p') - bw.AddInputChar('r') - bw.AddInputChar('d') + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyBackspace}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyBackspace}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyBackspace}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyBackspace}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'-'}}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) if bw.GetSuggestedBranch() != "chief/my-prd" { t.Errorf("expected 'chief/my-prd', got %q", bw.GetSuggestedBranch()) } // Invalid characters should be rejected - bw.AddInputChar(' ') - bw.AddInputChar('!') + bw.UpdateInput(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) if bw.GetSuggestedBranch() != "chief/my-prd" { t.Errorf("expected 'chief/my-prd' (unchanged), got %q", bw.GetSuggestedBranch()) } diff --git a/internal/tui/input_filters.go b/internal/tui/input_filters.go index 66495517..3badc31f 100644 --- a/internal/tui/input_filters.go +++ b/internal/tui/input_filters.go @@ -5,6 +5,10 @@ package tui // Ctrl+Left/Right word jumps. Defined once so the two widgets can't drift. var prdNameSeparators = []rune{'-', '_'} +// branchNameSeparators are the word-separator runes used by the BranchWarning +// branch-name editor for Ctrl+Left/Right word jumps. +var branchNameSeparators = []rune{'-', '_', '/'} + // filterPRDNameRunes drops any rune outside the allowed PRD-name character // set ([a-zA-Z0-9_-]). Returns a new slice so the caller can safely forward // the filtered KeyMsg to the textinput. @@ -19,6 +23,20 @@ func filterPRDNameRunes(runes []rune) []rune { return filtered } +// filterBranchNameRunes drops any rune outside the allowed branch-name +// character set ([a-zA-Z0-9_/-]). Returns a new slice so the caller can safely +// forward the filtered KeyMsg to the textinput. +func filterBranchNameRunes(runes []rune) []rune { + filtered := make([]rune, 0, len(runes)) + for _, r := range runes { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '/' { + filtered = append(filtered, r) + } + } + return filtered +} + // wordBackward returns the caret position after a word-jump-left from pos, // treating any rune in seps as a word separator. Mirrors bubbles' // wordBackward structure (skip separators, then skip non-separators) so From afa27ae5d2f9103adb0b04b77fe9787a54950122 Mon Sep 17 00:00:00 2001 From: Chief Agent Date: Fri, 24 Apr 2026 09:05:01 +0000 Subject: [PATCH 09/11] feat: US-009 - Automated tests for caret editing, paste, and max length in both widgets Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/tui/branch_warning.go | 14 + internal/tui/branch_warning_input_test.go | 322 ++++++++++++++++++++++ internal/tui/picker_input_test.go | 267 ++++++++++++++++++ 3 files changed, 603 insertions(+) create mode 100644 internal/tui/branch_warning_input_test.go create mode 100644 internal/tui/picker_input_test.go diff --git a/internal/tui/branch_warning.go b/internal/tui/branch_warning.go index c9871173..4b35d95a 100644 --- a/internal/tui/branch_warning.go +++ b/internal/tui/branch_warning.go @@ -60,6 +60,7 @@ func NewBranchWarning() *BranchWarning { ti := textinput.New() ti.Prompt = "" ti.CharLimit = maxBranchNameLength + ti.Width = branchWarningInputWidth(0) return &BranchWarning{ selectedIndex: 0, @@ -67,10 +68,23 @@ func NewBranchWarning() *BranchWarning { } } +// branchWarningInputWidth returns the textinput width for the branch-name field +// based on the current terminal width. Kept in one place so the textinput stays +// in parity with the surrounding lipgloss box (see renderBranchName). +func branchWarningInputWidth(terminalWidth int) int { + modalWidth := min(65, terminalWidth-10) + if modalWidth < 40 { + modalWidth = 40 + } + // modalWidth - 6 (border + padding on both sides) - 8 ("Branch: " prefix) + return modalWidth - 14 +} + // SetSize sets the dialog dimensions. func (b *BranchWarning) SetSize(width, height int) { b.width = width b.height = height + b.ti.Width = branchWarningInputWidth(width) } // SetContext sets the branch, PRD context, and worktree path for the warning. diff --git a/internal/tui/branch_warning_input_test.go b/internal/tui/branch_warning_input_test.go new file mode 100644 index 00000000..ab65491d --- /dev/null +++ b/internal/tui/branch_warning_input_test.go @@ -0,0 +1,322 @@ +package tui + +import ( + "reflect" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// newBranchEditMode returns a *BranchWarning in edit mode with the textinput +// pre-populated to value (cursor at end). Mirrors newPickerInputMode / +// newPRDNameSetup so all three widgets' tests share the same fixture style. +func newBranchEditMode(t *testing.T, value string) *BranchWarning { + t.Helper() + bw := NewBranchWarning() + bw.SetSize(80, 24) + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(DialogProtectedBranch) + bw.Reset() + bw.StartEditMode() + bw.ti.SetValue(value) + bw.ti.CursorEnd() + return bw +} + +// sendBranchKey dispatches msg through BranchWarning.UpdateInput — the +// dispatch path introduced in US-008 — returning bw for chaining. +func sendBranchKey(t *testing.T, bw *BranchWarning, msg tea.KeyMsg) *BranchWarning { + t.Helper() + bw.UpdateInput(msg) + return bw +} + +func TestBranchInput_LeftArrowMovesCaretLeft(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") // pos=10 + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyLeft}) + if got, want := bw.ti.Position(), 9; got != want { + t.Fatalf("after left: got pos %d, want %d", got, want) + } + if got, want := bw.GetSuggestedBranch(), "chief/auth"; got != want { + t.Fatalf("value should be unchanged: got %q, want %q", got, want) + } +} + +func TestBranchInput_RightArrowMovesCaretRight(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") + bw.ti.SetCursor(0) + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyRight}) + if got, want := bw.ti.Position(), 1; got != want { + t.Fatalf("after right: got pos %d, want %d", got, want) + } +} + +func TestBranchInput_HomeJumpsToStart(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyHome}) + if got, want := bw.ti.Position(), 0; got != want { + t.Fatalf("after home: got pos %d, want %d", got, want) + } +} + +func TestBranchInput_EndJumpsToEnd(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") + bw.ti.SetCursor(0) + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyEnd}) + if got, want := bw.ti.Position(), len("chief/auth"); got != want { + t.Fatalf("after end: got pos %d, want %d", got, want) + } +} + +// TestBranchInput_CtrlLeftStopsAtHyphenInSlashPath confirms the branch-name +// separator set includes `/` alongside `-`/`_`. On "chief/auth-system" with +// caret at end, Ctrl+Left lands just after the last `-` (pos 11, start of +// "system"). Locks in the US-008 decision to use branchNameSeparators for +// git-ref-style paths. +func TestBranchInput_CtrlLeftStopsAtHyphenInSlashPath(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth-system") // pos=17 + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyCtrlLeft}) + if got, want := bw.ti.Position(), 11; got != want { + t.Fatalf("ctrl+left on 'chief/auth-system': got pos %d, want %d", got, want) + } +} + +// TestBranchInput_CtrlLeftStopsAtSlash confirms `/` by itself is treated as +// a word separator. +func TestBranchInput_CtrlLeftStopsAtSlash(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") // pos=10, "chief/auth" + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyCtrlLeft}) + if got, want := bw.ti.Position(), 6; got != want { + t.Fatalf("ctrl+left on 'chief/auth': got pos %d, want %d (just after '/')", got, want) + } +} + +func TestBranchInput_CtrlRightJumpsToNextSeparator(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") + bw.ti.SetCursor(0) + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyCtrlRight}) + if got, want := bw.ti.Position(), 5; got != want { + t.Fatalf("ctrl+right on 'chief/auth' from pos 0: got pos %d, want %d", got, want) + } +} + +func TestBranchInput_InsertAtCaret(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") // pos=10 + bw.ti.SetCursor(6) // "chief/|auth" + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := bw.GetSuggestedBranch(), "chief/Xauth"; got != want { + t.Fatalf("insert at caret: got %q, want %q", got, want) + } + if got, want := bw.ti.Position(), 7; got != want { + t.Fatalf("cursor should advance: got pos %d, want %d", got, want) + } +} + +func TestBranchInput_BackspaceAtCaret(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") // pos=10 + bw.ti.SetCursor(6) // "chief/|auth" — backspace deletes '/' + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyBackspace}) + if got, want := bw.GetSuggestedBranch(), "chiefauth"; got != want { + t.Fatalf("backspace at caret: got %q, want %q", got, want) + } + if got, want := bw.ti.Position(), 5; got != want { + t.Fatalf("cursor should move left: got pos %d, want %d", got, want) + } +} + +func TestBranchInput_InvalidAsciiSilentlyDropped(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) + if got, want := bw.GetSuggestedBranch(), "chief/auth"; got != want { + t.Fatalf("invalid ASCII: got %q, want %q", got, want) + } +} + +// TestBranchInput_InvalidMultiByteRunesSilentlyDropped: é, 中, 🦄 must all be +// filtered by the ASCII-only branch-name charset. +func TestBranchInput_InvalidMultiByteRunesSilentlyDropped(t *testing.T) { + for _, r := range []rune{'é', '中', '🦄'} { + bw := newBranchEditMode(t, "chief/auth") + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + if got, want := bw.GetSuggestedBranch(), "chief/auth"; got != want { + t.Errorf("multi-byte rune %q: got %q, want %q", r, got, want) + } + } +} + +// TestBranchInput_SpaceKeyIsFiltered confirms a real spacebar press (which +// arrives with Type=KeySpace, not KeyRunes) is dropped. Same subtle US-003 +// bug as the picker/first-time-setup — must be tested explicitly here too. +func TestBranchInput_SpaceKeyIsFiltered(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") + pos := bw.ti.Position() + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + if got, want := bw.GetSuggestedBranch(), "chief/auth"; got != want { + t.Fatalf("space key should be filtered: got %q, want %q", got, want) + } + if got, want := bw.ti.Position(), pos; got != want { + t.Fatalf("filtered key should not advance cursor: got pos %d, want %d", got, want) + } +} + +// TestBranchInput_PasteKeepsSlash: branch-name charset includes `/`, so a +// paste like "feat/oops bad!" yields "feat/oopsbad" — NOT "feat-oops-bad" or +// "featoopsbad". Locks in the charset difference from the PRD-name filter. +func TestBranchInput_PasteKeepsSlash(t *testing.T) { + bw := newBranchEditMode(t, "") + sendBranchKey(t, bw, pasteMsg("feat/oops bad!")) + if got, want := bw.GetSuggestedBranch(), "feat/oopsbad"; got != want { + t.Fatalf("paste 'feat/oops bad!': got %q, want %q", got, want) + } +} + +// TestBranchInput_PasteTripleMaxLengthTruncates: paste 3*maxBranchNameLength +// valid characters, value must be truncated to exactly maxBranchNameLength. +// References the constant so tuning the cap later doesn't break this test. +func TestBranchInput_PasteTripleMaxLengthTruncates(t *testing.T) { + bw := newBranchEditMode(t, "") + sendBranchKey(t, bw, pasteMsg(strings.Repeat("a", maxBranchNameLength*3))) + if got := len(bw.GetSuggestedBranch()); got != maxBranchNameLength { + t.Fatalf("paste length: got %d, want %d", got, maxBranchNameLength) + } +} + +// TestBranchInput_TypingAtMaxLengthIsSilentNoOp: once at max length, typing +// any further allowed character is silently dropped. +func TestBranchInput_TypingAtMaxLengthIsSilentNoOp(t *testing.T) { + full := strings.Repeat("a", maxBranchNameLength) + bw := newBranchEditMode(t, full) + if got := len(bw.GetSuggestedBranch()); got != maxBranchNameLength { + t.Fatalf("precondition: value should be at max length, got %d", got) + } + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := bw.GetSuggestedBranch(), full; got != want { + t.Fatalf("typing at max length should not change value: got %q, want %q", got, want) + } + if got, want := bw.ti.Position(), maxBranchNameLength; got != want { + t.Fatalf("cursor should not advance: got pos %d, want %d", got, want) + } +} + +// TestBranchInput_EditValuePreservedAcrossEscapeAndRetoggle: editing the +// branch, pressing esc (CancelEditMode), then re-entering edit mode +// (StartEditMode) must preserve the edited value. Reset() is the only method +// that should reseed the value. +func TestBranchInput_EditValuePreservedAcrossEscapeAndRetoggle(t *testing.T) { + bw := NewBranchWarning() + bw.SetSize(80, 24) + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(DialogProtectedBranch) + bw.Reset() + + if got, want := bw.GetSuggestedBranch(), "chief/auth"; got != want { + t.Fatalf("initial value: got %q, want %q", got, want) + } + + bw.StartEditMode() + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyBackspace}) + bw.UpdateInput(tea.KeyMsg{Type: tea.KeyBackspace}) + if got, want := bw.GetSuggestedBranch(), "chief/au"; got != want { + t.Fatalf("after 2 backspaces: got %q, want %q", got, want) + } + + bw.CancelEditMode() + if bw.IsEditMode() { + t.Fatal("CancelEditMode should exit edit mode") + } + if got, want := bw.GetSuggestedBranch(), "chief/au"; got != want { + t.Fatalf("value after CancelEditMode: got %q, want %q", got, want) + } + + cmd := bw.StartEditMode() + if cmd == nil { + t.Fatal("StartEditMode re-entry should return a non-nil blink cmd") + } + if !bw.IsEditMode() { + t.Fatal("StartEditMode should enter edit mode") + } + if got, want := bw.GetSuggestedBranch(), "chief/au"; got != want { + t.Fatalf("value after re-StartEditMode: got %q, want %q", got, want) + } +} + +// TestBranchInput_StartEditModeReturnsBlinkCmd mirrors +// TestPickerInput_StartInputModeReturnsBlinkCmd: StartEditMode() must return +// a non-nil tea.Cmd that yields the textinput.Blink message type. +func TestBranchInput_StartEditModeReturnsBlinkCmd(t *testing.T) { + bw := NewBranchWarning() + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(DialogProtectedBranch) + bw.Reset() + cmd := bw.StartEditMode() + if cmd == nil { + t.Fatal("StartEditMode should return a non-nil tea.Cmd") + } + msg := cmd() + wantType := reflect.TypeOf(textinput.Blink()) + if gotType := reflect.TypeOf(msg); gotType != wantType { + t.Fatalf("cmd should produce %v, got %v", wantType, gotType) + } +} + +// TestBranchInput_CancelEditModeBlursTextinput: after cancel the textinput +// must be blurred so the caret stops blinking. +func TestBranchInput_CancelEditModeBlursTextinput(t *testing.T) { + bw := NewBranchWarning() + bw.SetContext("main", "auth", ".chief/worktrees/auth/") + bw.SetDialogContext(DialogProtectedBranch) + bw.Reset() + bw.StartEditMode() + if !bw.ti.Focused() { + t.Fatal("precondition: ti should be focused after StartEditMode") + } + bw.CancelEditMode() + if bw.ti.Focused() { + t.Fatal("CancelEditMode should leave the textinput blurred") + } +} + +// TestBranchInput_TextinputWidthMatchesModalContent (AC6): ti.Width tracks +// branchWarningInputWidth(terminalWidth) from construction and across SetSize. +func TestBranchInput_TextinputWidthMatchesModalContent(t *testing.T) { + bw := NewBranchWarning() + if got, want := bw.ti.Width, branchWarningInputWidth(0); got != want { + t.Fatalf("initial ti.Width: got %d, want %d", got, want) + } + bw.SetSize(120, 40) + if got, want := bw.ti.Width, branchWarningInputWidth(120); got != want { + t.Fatalf("ti.Width after SetSize: got %d, want %d", got, want) + } +} + +// TestBranchInput_EmptyAndPopulatedFieldHaveSameRenderedWidth (AC6): the +// edit-mode modal renders to the same max line width whether the textinput +// is empty or populated. Locks in the regression where a non-width-pinned +// textinput would grow the modal as characters were typed. +func TestBranchInput_EmptyAndPopulatedFieldHaveSameRenderedWidth(t *testing.T) { + empty := NewBranchWarning() + empty.SetSize(100, 40) + empty.SetContext("main", "auth", ".chief/worktrees/auth/") + empty.SetDialogContext(DialogProtectedBranch) + empty.Reset() + empty.StartEditMode() + empty.ti.SetValue("") + emptyView := empty.Render() + + populated := NewBranchWarning() + populated.SetSize(100, 40) + populated.SetContext("main", "auth", ".chief/worktrees/auth/") + populated.SetDialogContext(DialogProtectedBranch) + populated.Reset() + populated.StartEditMode() + populated.ti.SetValue("chief/auth-system") + populatedView := populated.Render() + + emptyMax := maxLineWidth(emptyView) + populatedMax := maxLineWidth(populatedView) + if emptyMax != populatedMax { + t.Fatalf("rendered max width should match: empty=%d populated=%d", emptyMax, populatedMax) + } +} diff --git a/internal/tui/picker_input_test.go b/internal/tui/picker_input_test.go new file mode 100644 index 00000000..7abc759f --- /dev/null +++ b/internal/tui/picker_input_test.go @@ -0,0 +1,267 @@ +package tui + +import ( + "reflect" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +// newPickerInputMode returns a *PRDPicker in input mode with the textinput +// pre-populated to value and the cursor at end. Mirrors newPRDNameSetup from +// first_time_setup_test.go. +func newPickerInputMode(t *testing.T, value string) *PRDPicker { + t.Helper() + p := NewPRDPicker(t.TempDir(), "", nil) + p.StartInputMode() + p.ti.SetValue(value) + p.ti.CursorEnd() + return p +} + +// sendPickerKey dispatches msg through PRDPicker.UpdateInput — the new +// dispatch path introduced in US-007 — returning the picker for chaining. +func sendPickerKey(t *testing.T, p *PRDPicker, msg tea.KeyMsg) *PRDPicker { + t.Helper() + p.UpdateInput(msg) + return p +} + +func TestPickerInput_LeftArrowMovesCaretLeft(t *testing.T) { + p := newPickerInputMode(t, "main") // pos=4 + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyLeft}) + if got, want := p.ti.Position(), 3; got != want { + t.Fatalf("after left: got pos %d, want %d", got, want) + } + if got, want := p.ti.Value(), "main"; got != want { + t.Fatalf("value should be unchanged: got %q, want %q", got, want) + } +} + +func TestPickerInput_RightArrowMovesCaretRight(t *testing.T) { + p := newPickerInputMode(t, "main") + p.ti.SetCursor(0) + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyRight}) + if got, want := p.ti.Position(), 1; got != want { + t.Fatalf("after right: got pos %d, want %d", got, want) + } +} + +func TestPickerInput_HomeJumpsToStart(t *testing.T) { + p := newPickerInputMode(t, "main") + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyHome}) + if got, want := p.ti.Position(), 0; got != want { + t.Fatalf("after home: got pos %d, want %d", got, want) + } +} + +func TestPickerInput_EndJumpsToEnd(t *testing.T) { + p := newPickerInputMode(t, "main") + p.ti.SetCursor(0) + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyEnd}) + if got, want := p.ti.Position(), 4; got != want { + t.Fatalf("after end: got pos %d, want %d", got, want) + } +} + +// TestPickerInput_CtrlLeftStopsAtHyphen confirms the shared word-jump helper +// treats `-` as a separator — stopping Ctrl+Left just past the hyphen so +// inserting 'X' at the new caret yields "foo-Xbar". +func TestPickerInput_CtrlLeftStopsAtHyphen(t *testing.T) { + p := newPickerInputMode(t, "foo-bar") // pos=7 + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyCtrlLeft}) + if got, want := p.ti.Position(), 4; got != want { + t.Fatalf("ctrl+left on 'foo-bar': got pos %d, want %d", got, want) + } + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := p.ti.Value(), "foo-Xbar"; got != want { + t.Fatalf("ctrl+left + 'X' on 'foo-bar': got %q, want %q", got, want) + } +} + +// TestPickerInput_CtrlLeftStopsAtUnderscore confirms `_` is also a separator +// for the PRD-name charset. +func TestPickerInput_CtrlLeftStopsAtUnderscore(t *testing.T) { + p := newPickerInputMode(t, "foo_bar") // pos=7 + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyCtrlLeft}) + if got, want := p.ti.Position(), 4; got != want { + t.Fatalf("ctrl+left on 'foo_bar': got pos %d, want %d", got, want) + } +} + +func TestPickerInput_CtrlRightJumpsToNextSeparator(t *testing.T) { + p := newPickerInputMode(t, "foo-bar") + p.ti.SetCursor(0) + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyCtrlRight}) + if got, want := p.ti.Position(), 3; got != want { + t.Fatalf("ctrl+right on 'foo-bar' from pos 0: got pos %d, want %d", got, want) + } +} + +func TestPickerInput_InsertAtCaret(t *testing.T) { + p := newPickerInputMode(t, "main") // value=main, pos=4 + p.ti.SetCursor(2) // "ma|in" + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := p.ti.Value(), "maXin"; got != want { + t.Fatalf("insert at caret: got %q, want %q", got, want) + } + if got, want := p.ti.Position(), 3; got != want { + t.Fatalf("cursor should advance past inserted rune: got pos %d, want %d", got, want) + } +} + +func TestPickerInput_BackspaceAtCaret(t *testing.T) { + p := newPickerInputMode(t, "main") + p.ti.SetCursor(2) // "ma|in" — backspace deletes 'a' + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyBackspace}) + if got, want := p.ti.Value(), "min"; got != want { + t.Fatalf("backspace at caret: got %q, want %q", got, want) + } + if got, want := p.ti.Position(), 1; got != want { + t.Fatalf("cursor should move left after backspace: got pos %d, want %d", got, want) + } +} + +func TestPickerInput_InvalidAsciiSilentlyDropped(t *testing.T) { + p := newPickerInputMode(t, "main") + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}) + if got, want := p.ti.Value(), "main"; got != want { + t.Fatalf("invalid ASCII: got %q, want %q", got, want) + } +} + +// TestPickerInput_InvalidMultiByteRunesSilentlyDropped: é, 中, 🦄 must all be +// filtered by the ASCII-only PRD-name charset. +func TestPickerInput_InvalidMultiByteRunesSilentlyDropped(t *testing.T) { + for _, r := range []rune{'é', '中', '🦄'} { + p := newPickerInputMode(t, "main") + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + if got, want := p.ti.Value(), "main"; got != want { + t.Errorf("multi-byte rune %q: got %q, want %q", r, got, want) + } + } +} + +// TestPickerInput_SpaceKeyIsFiltered confirms a real spacebar press (which +// arrives with Type=KeySpace, not KeyRunes) is dropped before reaching the +// textinput. Mirrors TestPRDName_SpaceKeyIsFiltered — the subtle US-003 bug +// that must be tested explicitly on every widget. +func TestPickerInput_SpaceKeyIsFiltered(t *testing.T) { + p := newPickerInputMode(t, "main") + p.ti.SetCursor(2) + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) + if got, want := p.ti.Value(), "main"; got != want { + t.Fatalf("space key should be filtered: got %q, want %q", got, want) + } + if got, want := p.ti.Position(), 2; got != want { + t.Fatalf("filtered key should not advance cursor: got pos %d, want %d", got, want) + } +} + +// TestPickerInput_PasteFiltersInvalidChars: paste "my feature/v2!" → "myfeaturev2". +func TestPickerInput_PasteFiltersInvalidChars(t *testing.T) { + p := newPickerInputMode(t, "") + sendPickerKey(t, p, pasteMsg("my feature/v2!")) + if got, want := p.ti.Value(), "myfeaturev2"; got != want { + t.Fatalf("paste filtered: got %q, want %q", got, want) + } +} + +// TestPickerInput_PasteTripleMaxLengthTruncates: paste 3*maxPRDNameLength +// valid characters, value must be truncated to exactly maxPRDNameLength. +// References the constant so tuning the cap later doesn't break this test. +func TestPickerInput_PasteTripleMaxLengthTruncates(t *testing.T) { + p := newPickerInputMode(t, "") + sendPickerKey(t, p, pasteMsg(strings.Repeat("a", maxPRDNameLength*3))) + if got := len(p.ti.Value()); got != maxPRDNameLength { + t.Fatalf("paste length: got %d, want %d", got, maxPRDNameLength) + } +} + +// TestPickerInput_TypingAtMaxLengthIsSilentNoOp: once at max length, typing +// any further allowed character is silently dropped (value unchanged, cursor +// unchanged). +func TestPickerInput_TypingAtMaxLengthIsSilentNoOp(t *testing.T) { + full := strings.Repeat("a", maxPRDNameLength) + p := newPickerInputMode(t, full) + if got := len(p.ti.Value()); got != maxPRDNameLength { + t.Fatalf("precondition: value should be at max length, got %d", got) + } + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}}) + if got, want := p.ti.Value(), full; got != want { + t.Fatalf("typing at max length should not change value: got %q, want %q", got, want) + } + if got, want := p.ti.Position(), maxPRDNameLength; got != want { + t.Fatalf("cursor should not advance past max length: got pos %d, want %d", got, want) + } +} + +// TestPickerInput_StartInputModeReturnsBlinkCmd mirrors US-006's +// TestUS006_GitignoreToPRDNameBlinkCmd: StartInputMode() must return a non-nil +// tea.Cmd that yields the textinput.Blink message type — otherwise the caret +// never blinks (FR-10 regression). +func TestPickerInput_StartInputModeReturnsBlinkCmd(t *testing.T) { + p := NewPRDPicker(t.TempDir(), "", nil) + cmd := p.StartInputMode() + if cmd == nil { + t.Fatal("StartInputMode should return a non-nil tea.Cmd") + } + msg := cmd() + wantType := reflect.TypeOf(textinput.Blink()) + if gotType := reflect.TypeOf(msg); gotType != wantType { + t.Fatalf("cmd should produce %v, got %v", wantType, gotType) + } +} + +// TestPickerInput_CancelInputModeBlursTextinput: after cancel the textinput +// must be blurred so the caret stops blinking. +func TestPickerInput_CancelInputModeBlursTextinput(t *testing.T) { + p := NewPRDPicker(t.TempDir(), "", nil) + p.StartInputMode() + if !p.ti.Focused() { + t.Fatal("precondition: ti should be focused after StartInputMode") + } + p.CancelInputMode() + if p.ti.Focused() { + t.Fatal("CancelInputMode should leave the textinput blurred") + } +} + +// TestPickerInput_TextinputWidthMatchesModalContent (AC6): ti.Width tracks +// pickerInputWidth(terminalWidth) from construction and across SetSize. +func TestPickerInput_TextinputWidthMatchesModalContent(t *testing.T) { + p := NewPRDPicker(t.TempDir(), "", nil) + if got, want := p.ti.Width, pickerInputWidth(0); got != want { + t.Fatalf("initial ti.Width: got %d, want %d", got, want) + } + p.SetSize(120, 40) + if got, want := p.ti.Width, pickerInputWidth(120); got != want { + t.Fatalf("ti.Width after SetSize: got %d, want %d", got, want) + } +} + +// TestPickerInput_EmptyAndPopulatedFieldHaveSameRenderedWidth (AC6): the +// input-mode modal renders to the same max line width whether the textinput +// is empty or populated. Locks in the regression where a custom renderer +// would jitter the modal width as characters were typed. +func TestPickerInput_EmptyAndPopulatedFieldHaveSameRenderedWidth(t *testing.T) { + empty := NewPRDPicker(t.TempDir(), "", nil) + empty.SetSize(100, 40) + empty.StartInputMode() + empty.ti.SetValue("") + emptyView := empty.Render() + + populated := NewPRDPicker(t.TempDir(), "", nil) + populated.SetSize(100, 40) + populated.StartInputMode() + populated.ti.SetValue("main") + populatedView := populated.Render() + + emptyMax := maxLineWidth(emptyView) + populatedMax := maxLineWidth(populatedView) + if emptyMax != populatedMax { + t.Fatalf("rendered max width should match: empty=%d populated=%d", emptyMax, populatedMax) + } +} From d1f4c966dbf4ced6d860d8af947ad2f732f83eb6 Mon Sep 17 00:00:00 2001 From: Jeroen D Date: Fri, 24 Apr 2026 14:12:35 +0000 Subject: [PATCH 10/11] fix(tui): wire ctrl+c through picker and branch-warning input modes 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 --- internal/tui/app.go | 4 ++ internal/tui/branch_warning.go | 12 ++-- internal/tui/branch_warning_input_test.go | 62 ++++++++++++++++ internal/tui/first_time_setup.go | 8 +-- internal/tui/input_filters.go | 11 +++ internal/tui/picker.go | 9 +-- internal/tui/picker_input_test.go | 88 +++++++++++++++++++++++ 7 files changed, 181 insertions(+), 13 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index b199488b..4c112ed9 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1094,6 +1094,8 @@ func (a App) handleBranchWarningKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Handle edit mode input if a.branchWarning.IsEditMode() { switch msg.String() { + case "ctrl+c": + return a.tryQuit() case "esc": // Cancel edit mode a.branchWarning.CancelEditMode() @@ -1830,6 +1832,8 @@ func (a App) handlePickerKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Handle input mode (creating new PRD) if a.picker.IsInputMode() { switch msg.String() { + case "ctrl+c": + return a.tryQuit() case "esc": a.picker.CancelInputMode() return a, nil diff --git a/internal/tui/branch_warning.go b/internal/tui/branch_warning.go index 4b35d95a..a7870a2d 100644 --- a/internal/tui/branch_warning.go +++ b/internal/tui/branch_warning.go @@ -217,6 +217,9 @@ func (b *BranchWarning) IsEditMode() bool { func (b *BranchWarning) StartEditMode() tea.Cmd { b.editMode = true b.ti.Focus() + // CursorEnd on re-entry is intentional (PRD US-008 AC): the value itself + // is preserved across e/esc/e toggles, but the caret jumps to the end of + // the (possibly edited) string rather than restoring its prior position. b.ti.CursorEnd() return textinput.Blink } @@ -231,8 +234,9 @@ func (b *BranchWarning) CancelEditMode() { // branch-name charset filter to rune input and overriding Ctrl+Left/Right (and // their Alt-variants) to treat '-', '_', and '/' as word separators. // -// Callers MUST have already matched edit-mode control keys (esc, enter) before -// forwarding here (see FR-9). +// Callers MUST have already matched edit-mode control keys (esc, enter, +// ctrl+c) before forwarding here (see FR-9 in +// .chief/prds/extend-cursor-keys-to-tui-dialogs/prd.md). func (b *BranchWarning) UpdateInput(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "ctrl+left", "alt+left", "alt+b": @@ -243,7 +247,7 @@ func (b *BranchWarning) UpdateInput(msg tea.KeyMsg) tea.Cmd { return nil } - if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { + if isTextualKey(msg) { msg.Runes = filterBranchNameRunes(msg.Runes) } @@ -290,7 +294,7 @@ func (b *BranchWarning) Render() string { footerStyle := lipgloss.NewStyle().Foreground(MutedColor) if b.editMode { - content.WriteString(footerStyle.Render("Enter: confirm Esc: cancel edit")) + content.WriteString(footerStyle.Render("Enter: confirm Esc: cancel edit Ctrl+C: quit")) } else { content.WriteString(footerStyle.Render("↑/↓: Navigate Enter: Select e: Edit branch Esc: Cancel")) } diff --git a/internal/tui/branch_warning_input_test.go b/internal/tui/branch_warning_input_test.go index ab65491d..4cca4827 100644 --- a/internal/tui/branch_warning_input_test.go +++ b/internal/tui/branch_warning_input_test.go @@ -320,3 +320,65 @@ func TestBranchInput_EmptyAndPopulatedFieldHaveSameRenderedWidth(t *testing.T) { t.Fatalf("rendered max width should match: empty=%d populated=%d", emptyMax, populatedMax) } } + +// TestBranchInput_CtrlCQuitsFromEditMode locks in the ctrl+c dispatch at +// app.go:1095: while the branch-warning modal is in edit mode, ctrl+c must +// quit the app (matching FirstTimeSetup.handlePRDNameKeys), not slip through +// to UpdateInput where textinput.Update would silently swallow it. +func TestBranchInput_CtrlCQuitsFromEditMode(t *testing.T) { + bw := newBranchEditMode(t, "chief/auth") + app := App{branchWarning: bw, viewMode: ViewBranchWarning} + + _, cmd := app.handleBranchWarningKeys(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd == nil { + t.Fatal("ctrl+c in branch-warning edit mode should return a non-nil cmd (tea.Quit)") + } + if got, want := reflect.TypeOf(cmd()), reflect.TypeOf(tea.Quit()); got != want { + t.Fatalf("ctrl+c cmd type: got %v, want %v", got, want) + } + if got, want := bw.ti.Value(), "chief/auth"; got != want { + t.Fatalf("ctrl+c must not mutate the textinput value: got %q, want %q", got, want) + } +} + +// TestBranchInput_CtrlCOpensQuitConfirmWhenLoopRunning mirrors the picker +// counterpart: when a loop is running, Ctrl+C in branch-warning edit mode +// must open the quit-confirmation dialog (not quit immediately). Canceling +// with Esc must return the user to the branch-warning modal with edit mode +// still active and the (possibly edited) branch name preserved. +func TestBranchInput_CtrlCOpensQuitConfirmWhenLoopRunning(t *testing.T) { + bw := newBranchEditMode(t, "chief/my-edit") + app := App{ + branchWarning: bw, + manager: managerWithRunningPRD(t, "current"), + quitConfirm: NewQuitConfirmation(), + viewMode: ViewBranchWarning, + } + + model, cmd := app.handleBranchWarningKeys(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd != nil { + t.Fatal("ctrl+c must not return tea.Quit while a loop is running") + } + after := model.(App) + if after.viewMode != ViewQuitConfirm { + t.Fatalf("viewMode after ctrl+c: got %v, want ViewQuitConfirm", after.viewMode) + } + if !bw.IsEditMode() { + t.Fatal("branch-warning must remain in edit mode across the quit-confirm detour") + } + if got, want := bw.ti.Value(), "chief/my-edit"; got != want { + t.Fatalf("branch value after ctrl+c: got %q, want %q", got, want) + } + + model, _ = after.handleQuitConfirmKeys(tea.KeyMsg{Type: tea.KeyEsc}) + back := model.(App) + if back.viewMode != ViewBranchWarning { + t.Fatalf("viewMode after cancel: got %v, want ViewBranchWarning", back.viewMode) + } + if !bw.IsEditMode() { + t.Fatal("branch-warning must still be in edit mode after cancel") + } + if got, want := bw.ti.Value(), "chief/my-edit"; got != want { + t.Fatalf("branch value after cancel: got %q, want %q", got, want) + } +} diff --git a/internal/tui/first_time_setup.go b/internal/tui/first_time_setup.go index 6f764963..67731e6a 100644 --- a/internal/tui/first_time_setup.go +++ b/internal/tui/first_time_setup.go @@ -238,12 +238,10 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // Filter rune input against the allowed character set before forwarding to - // the textinput. A bare spacebar press arrives as KeySpace (not KeyRunes), - // so handle both — otherwise spaces would slip past the filter into the - // buffer. Non-rune keys (arrows, backspace, Home/End, Ctrl+arrows, + // the textinput. Non-rune keys (arrows, backspace, Home/End, Ctrl+arrows, // Alt+Backspace, etc.) pass through unchanged so the bubbles default key - // bindings keep working. - if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { + // bindings keep working. See isTextualKey for the KeySpace rationale. + if isTextualKey(msg) { msg.Runes = filterPRDNameRunes(msg.Runes) } diff --git a/internal/tui/input_filters.go b/internal/tui/input_filters.go index 3badc31f..30d2f185 100644 --- a/internal/tui/input_filters.go +++ b/internal/tui/input_filters.go @@ -1,5 +1,16 @@ package tui +import tea "github.com/charmbracelet/bubbletea" + +// isTextualKey reports whether a KeyMsg carries rune input that must run +// through a charset filter. bubbles delivers a bare spacebar press as +// KeySpace (not KeyRunes), so both types must be handled — otherwise spaces +// would slip past the filter. Shared by FirstTimeSetup, PRDPicker, and +// BranchWarning so the three widgets can't drift on this corner case. +func isTextualKey(msg tea.KeyMsg) bool { + return msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace +} + // prdNameSeparators are the word-separator runes used by PRD-name editors // (both FirstTimeSetup's StepPRDName and PRDPicker's new-PRD-name input) for // Ctrl+Left/Right word jumps. Defined once so the two widgets can't drift. diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 9496c6db..679ae154 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -324,8 +324,9 @@ func (p *PRDPicker) GetInputValue() string { // (and their Alt-variants) to treat `-` and `_` as word separators. Non-rune // keys (arrows, backspace, Home/End, etc.) pass through unchanged. // -// Callers MUST have already matched input-mode control keys (esc, enter) -// before forwarding here (see FR-9). +// Callers MUST have already matched input-mode control keys (esc, enter, +// ctrl+c) before forwarding here (see FR-9 in +// .chief/prds/extend-cursor-keys-to-tui-dialogs/prd.md). func (p *PRDPicker) UpdateInput(msg tea.KeyMsg) tea.Cmd { switch msg.String() { case "ctrl+left", "alt+left", "alt+b": @@ -336,7 +337,7 @@ func (p *PRDPicker) UpdateInput(msg tea.KeyMsg) tea.Cmd { return nil } - if msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace { + if isTextualKey(msg) { msg.Runes = filterPRDNameRunes(msg.Runes) } @@ -540,7 +541,7 @@ func (p *PRDPicker) Render() string { var shortcuts string if p.inputMode { - shortcuts = "Enter: create │ Esc: cancel" + shortcuts = "Enter: create │ Esc: cancel │ Ctrl+C: quit" } else { // Build context-sensitive shortcuts based on selected entry's state shortcuts = p.buildFooterShortcuts() diff --git a/internal/tui/picker_input_test.go b/internal/tui/picker_input_test.go index 7abc759f..17170754 100644 --- a/internal/tui/picker_input_test.go +++ b/internal/tui/picker_input_test.go @@ -7,6 +7,8 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + + "github.com/minicodemonkey/chief/internal/loop" ) // newPickerInputMode returns a *PRDPicker in input mode with the textinput @@ -265,3 +267,89 @@ func TestPickerInput_EmptyAndPopulatedFieldHaveSameRenderedWidth(t *testing.T) { t.Fatalf("rendered max width should match: empty=%d populated=%d", emptyMax, populatedMax) } } + +// TestPickerInput_CtrlCQuitsFromInputMode locks in the ctrl+c dispatch at +// app.go:1831: while the picker is in input mode, ctrl+c must quit the app +// (matching FirstTimeSetup.handlePRDNameKeys), not slip through to +// UpdateInput where textinput.Update would silently swallow it. +func TestPickerInput_CtrlCQuitsFromInputMode(t *testing.T) { + picker := NewPRDPicker(t.TempDir(), "", nil) + picker.StartInputMode() + picker.ti.SetValue("main") + app := App{picker: picker, viewMode: ViewPicker} + + _, cmd := app.handlePickerKeys(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd == nil { + t.Fatal("ctrl+c in picker input mode should return a non-nil cmd (tea.Quit)") + } + if got, want := reflect.TypeOf(cmd()), reflect.TypeOf(tea.Quit()); got != want { + t.Fatalf("ctrl+c cmd type: got %v, want %v", got, want) + } + if got, want := picker.ti.Value(), "main"; got != want { + t.Fatalf("ctrl+c must not mutate the textinput value: got %q, want %q", got, want) + } +} + +// managerWithRunningPRD returns a *loop.Manager with one instance whose State +// is forced to LoopStateRunning. Used to exercise the tryQuit branch that +// routes through the quit-confirmation dialog. +func managerWithRunningPRD(t *testing.T, name string) *loop.Manager { + t.Helper() + m := loop.NewManager(10, nil) + if err := m.Register(name, "/tmp/"+name); err != nil { + t.Fatalf("Register: %v", err) + } + inst := m.GetInstance(name) + if inst == nil { + t.Fatal("registered instance not retrievable") + } + // Single-goroutine test — no concurrent reader, so writing State + // directly is safe without locking instance.mu. + inst.State = loop.LoopStateRunning + return m +} + +// TestPickerInput_CtrlCOpensQuitConfirmWhenLoopRunning covers the other tryQuit +// branch: when a loop is running, Ctrl+C in picker input mode must open the +// quit-confirmation dialog (not quit immediately). The in-progress input +// value must survive the detour — canceling the confirmation with Esc +// returns the user to the picker with the textinput still populated. +func TestPickerInput_CtrlCOpensQuitConfirmWhenLoopRunning(t *testing.T) { + picker := NewPRDPicker(t.TempDir(), "", nil) + picker.StartInputMode() + picker.ti.SetValue("main") + app := App{ + picker: picker, + manager: managerWithRunningPRD(t, "current"), + quitConfirm: NewQuitConfirmation(), + viewMode: ViewPicker, + } + + model, cmd := app.handlePickerKeys(tea.KeyMsg{Type: tea.KeyCtrlC}) + if cmd != nil { + t.Fatal("ctrl+c must not return tea.Quit while a loop is running") + } + after := model.(App) + if after.viewMode != ViewQuitConfirm { + t.Fatalf("viewMode after ctrl+c: got %v, want ViewQuitConfirm", after.viewMode) + } + if !picker.IsInputMode() { + t.Fatal("picker must remain in input mode across the quit-confirm detour") + } + if got, want := picker.ti.Value(), "main"; got != want { + t.Fatalf("picker value after ctrl+c: got %q, want %q", got, want) + } + + // Cancel the quit-confirmation with Esc and verify the picker state survives. + model, _ = after.handleQuitConfirmKeys(tea.KeyMsg{Type: tea.KeyEsc}) + back := model.(App) + if back.viewMode != ViewPicker { + t.Fatalf("viewMode after cancel: got %v, want ViewPicker", back.viewMode) + } + if !picker.IsInputMode() { + t.Fatal("picker must still be in input mode after cancel") + } + if got, want := picker.ti.Value(), "main"; got != want { + t.Fatalf("picker value after cancel: got %q, want %q", got, want) + } +} From 66a31e4e4c14e5dcbaca6aa9dc9a9fe757ae723f Mon Sep 17 00:00:00 2001 From: Jeroen D Date: Fri, 24 Apr 2026 21:28:30 +0000 Subject: [PATCH 11/11] feat(tui): normalize pasted text in TUI input dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/loop/manager.go | 19 +++++ internal/tui/branch_warning.go | 6 +- internal/tui/branch_warning_input_test.go | 42 ++++++++++- internal/tui/first_time_setup.go | 9 ++- internal/tui/first_time_setup_test.go | 54 ++++++++++--- internal/tui/input_filters.go | 84 ++++++++++++++++++--- internal/tui/input_filters_test.go | 92 +++++++++++++++++++++++ internal/tui/picker.go | 6 +- internal/tui/picker_input_test.go | 49 ++++++++++-- 9 files changed, 324 insertions(+), 37 deletions(-) create mode 100644 internal/tui/input_filters_test.go diff --git a/internal/loop/manager.go b/internal/loop/manager.go index ec5aa203..fbda15b9 100644 --- a/internal/loop/manager.go +++ b/internal/loop/manager.go @@ -469,6 +469,25 @@ func (m *Manager) GetInstance(name string) *LoopInstance { } } +// SetInstanceState forces the state of a registered instance. Intended for +// tests that need to exercise state-conditional code paths without actually +// starting a loop (which requires a Provider). Returns false if no instance +// is registered under name. +func (m *Manager) SetInstanceState(name string, state LoopState) bool { + m.mu.RLock() + instance, exists := m.instances[name] + m.mu.RUnlock() + + if !exists { + return false + } + + instance.mu.Lock() + instance.State = state + instance.mu.Unlock() + return true +} + // GetAllInstances returns a snapshot of all loop instances. func (m *Manager) GetAllInstances() []*LoopInstance { m.mu.RLock() diff --git a/internal/tui/branch_warning.go b/internal/tui/branch_warning.go index a7870a2d..aef0790c 100644 --- a/internal/tui/branch_warning.go +++ b/internal/tui/branch_warning.go @@ -248,7 +248,11 @@ func (b *BranchWarning) UpdateInput(msg tea.KeyMsg) tea.Cmd { } if isTextualKey(msg) { - msg.Runes = filterBranchNameRunes(msg.Runes) + if isPasteLike(msg) { + msg.Runes = normalizePastedRunes(msg.Runes, isAllowedBranchNameRune) + } else { + msg.Runes = filterBranchNameRunes(msg.Runes) + } } var cmd tea.Cmd diff --git a/internal/tui/branch_warning_input_test.go b/internal/tui/branch_warning_input_test.go index 4cca4827..2770cac5 100644 --- a/internal/tui/branch_warning_input_test.go +++ b/internal/tui/branch_warning_input_test.go @@ -162,12 +162,13 @@ func TestBranchInput_SpaceKeyIsFiltered(t *testing.T) { } // TestBranchInput_PasteKeepsSlash: branch-name charset includes `/`, so a -// paste like "feat/oops bad!" yields "feat/oopsbad" — NOT "feat-oops-bad" or -// "featoopsbad". Locks in the charset difference from the PRD-name filter. +// paste like "feat/oops bad!" yields "feat/oops-bad" — the interior space +// collapses to '-' while '/' is preserved (charset difference from the +// PRD-name filter), and the trailing '!' is stripped. func TestBranchInput_PasteKeepsSlash(t *testing.T) { bw := newBranchEditMode(t, "") sendBranchKey(t, bw, pasteMsg("feat/oops bad!")) - if got, want := bw.GetSuggestedBranch(), "feat/oopsbad"; got != want { + if got, want := bw.GetSuggestedBranch(), "feat/oops-bad"; got != want { t.Fatalf("paste 'feat/oops bad!': got %q, want %q", got, want) } } @@ -183,6 +184,41 @@ func TestBranchInput_PasteTripleMaxLengthTruncates(t *testing.T) { } } +// TestBranchInput_PasteCollapsesInteriorRunAndStripsEnds exercises the full +// paste normalization rule for the branch-name input: leading/trailing +// invalid runes are stripped, interior runs collapse to '-', and consecutive +// '-' collapse. '/' stays since it is in the branch-name charset. +func TestBranchInput_PasteCollapsesInteriorRunAndStripsEnds(t *testing.T) { + bw := newBranchEditMode(t, "") + sendBranchKey(t, bw, pasteMsg("!!feat/oops---@@bar!!")) + if got, want := bw.GetSuggestedBranch(), "feat/oops-bar"; got != want { + t.Fatalf("normalized paste: got %q, want %q", got, want) + } +} + +// TestBranchInput_PasteAllInvalidIsNoOp verifies that an all-invalid paste +// normalizes to empty and leaves the value unchanged. +func TestBranchInput_PasteAllInvalidIsNoOp(t *testing.T) { + bw := newBranchEditMode(t, "feat/existing") + sendBranchKey(t, bw, pasteMsg("! @ # $")) + if got, want := bw.GetSuggestedBranch(), "feat/existing"; got != want { + t.Fatalf("all-invalid paste should not change value: got %q, want %q", got, want) + } +} + +// TestBranchInput_PasteWithoutBracketedFlagAlsoNormalized mirrors the +// picker/FirstTimeSetup coverage at the branch-warning widget: a multi-rune +// KeyRunes event without Paste=true is treated as a paste and normalized. +// '/' is in the branch-name charset so it is preserved; the interior space +// collapses to '-' and the trailing '!' is stripped. +func TestBranchInput_PasteWithoutBracketedFlagAlsoNormalized(t *testing.T) { + bw := newBranchEditMode(t, "") + sendBranchKey(t, bw, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("feat/oops bad!")}) + if got, want := bw.GetSuggestedBranch(), "feat/oops-bad"; got != want { + t.Fatalf("non-bracketed multi-rune paste: got %q, want %q", got, want) + } +} + // TestBranchInput_TypingAtMaxLengthIsSilentNoOp: once at max length, typing // any further allowed character is silently dropped. func TestBranchInput_TypingAtMaxLengthIsSilentNoOp(t *testing.T) { diff --git a/internal/tui/first_time_setup.go b/internal/tui/first_time_setup.go index 67731e6a..ac291cab 100644 --- a/internal/tui/first_time_setup.go +++ b/internal/tui/first_time_setup.go @@ -240,9 +240,14 @@ func (f FirstTimeSetup) handlePRDNameKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Filter rune input against the allowed character set before forwarding to // the textinput. Non-rune keys (arrows, backspace, Home/End, Ctrl+arrows, // Alt+Backspace, etc.) pass through unchanged so the bubbles default key - // bindings keep working. See isTextualKey for the KeySpace rationale. + // bindings keep working. See isTextualKey for the KeySpace rationale and + // isPasteLike for the paste-vs-typing distinction. if isTextualKey(msg) { - msg.Runes = filterPRDNameRunes(msg.Runes) + if isPasteLike(msg) { + msg.Runes = normalizePastedRunes(msg.Runes, isAllowedPRDNameRune) + } else { + msg.Runes = filterPRDNameRunes(msg.Runes) + } } before := f.ti.Value() diff --git a/internal/tui/first_time_setup_test.go b/internal/tui/first_time_setup_test.go index e27c4962..8e3f0f45 100644 --- a/internal/tui/first_time_setup_test.go +++ b/internal/tui/first_time_setup_test.go @@ -450,12 +450,13 @@ func TestPRDName_PasteAllValidInsertsAtCaret(t *testing.T) { } } -// TestPRDName_PasteFiltersInvalidChars (US-004 AC2): invalid characters are -// silently dropped and no error is shown. +// TestPRDName_PasteFiltersInvalidChars (US-004 AC2): interior runs of +// invalid characters collapse to a single '-', trailing invalid characters +// are stripped, and no error is shown. func TestPRDName_PasteFiltersInvalidChars(t *testing.T) { f := newPRDNameSetup(t, "") f = sendKey(t, f, pasteMsg("my feature/v2!")) - if got, want := f.ti.Value(), "myfeaturev2"; got != want { + if got, want := f.ti.Value(), "my-feature-v2"; got != want { t.Fatalf("paste with invalid chars: got %q, want %q", got, want) } if f.prdNameError != "" { @@ -494,12 +495,13 @@ func TestPRDName_PasteAtMiddleCaretSplices(t *testing.T) { } // TestPRDName_PasteAtMiddleCaretSplicesWithFiltering combines AC2 and AC4: an -// in-middle paste with invalid chars splices only the valid subset. +// in-middle paste with invalid chars splices the normalized paste (interior +// runs of invalid chars collapsed to '-') into the middle of the value. func TestPRDName_PasteAtMiddleCaretSplicesWithFiltering(t *testing.T) { f := newPRDNameSetup(t, "main") f.ti.SetCursor(2) f = sendKey(t, f, pasteMsg("X Y/Z")) - if got, want := f.ti.Value(), "maXYZin"; got != want { + if got, want := f.ti.Value(), "maX-Y-Zin"; got != want { t.Fatalf("filtered paste mid-buffer: got %q, want %q", got, want) } } @@ -537,17 +539,44 @@ func TestPRDName_PasteAllInvalidIsNoOp(t *testing.T) { } } -// TestPRDName_PasteWithoutBracketedFlagAlsoFiltered verifies the same filter -// path handles a multi-rune KeyRunes event that lacks Paste=true (the -// fallback path when bracketed paste is disabled in the terminal). -func TestPRDName_PasteWithoutBracketedFlagAlsoFiltered(t *testing.T) { +// TestPRDName_PasteWithoutBracketedFlagAlsoNormalized verifies that a +// multi-rune KeyRunes event is treated as a paste even when Paste=false (the +// fallback path for terminals without bracketed paste): runs of invalid +// characters collapse to '-' and trailing invalid characters are stripped, +// matching the bracketed-paste path. +func TestPRDName_PasteWithoutBracketedFlagAlsoNormalized(t *testing.T) { f := newPRDNameSetup(t, "") f = sendKey(t, f, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ab/cd!")}) - if got, want := f.ti.Value(), "abcd"; got != want { + if got, want := f.ti.Value(), "ab-cd"; got != want { t.Fatalf("non-bracketed multi-rune paste: got %q, want %q", got, want) } } +// TestPRDName_PasteDoesNotDedupeAcrossBoundary locks the deliberate decision +// that paste normalization looks only at the pasted content: if the existing +// field ends with '-' and the paste starts with '-', the result contains +// "--" (the paste-side '-' is a leading valid rune, not collapsed against +// the field's trailing '-'). +func TestPRDName_PasteDoesNotDedupeAcrossBoundary(t *testing.T) { + f := newPRDNameSetup(t, "abc-") + f = sendKey(t, f, pasteMsg("-xyz")) + if got, want := f.ti.Value(), "abc--xyz"; got != want { + t.Fatalf("paste should not dedupe across field boundary: got %q, want %q", got, want) + } +} + +// TestPRDName_PasteCollapsesInteriorRunAndStripsEnds exercises the full +// normalization rule end-to-end at the widget level: leading invalid runes +// are stripped, an interior run of invalid runes collapses to a single '-', +// consecutive '-' collapse, and trailing invalid runes are stripped. +func TestPRDName_PasteCollapsesInteriorRunAndStripsEnds(t *testing.T) { + f := newPRDNameSetup(t, "") + f = sendKey(t, f, pasteMsg("!!foo---@@bar!!")) + if got, want := f.ti.Value(), "foo-bar"; got != want { + t.Fatalf("normalized paste: got %q, want %q", got, want) + } +} + // TestPRDName_CharLimitMatchesConstant (US-005 AC2/AC5): the textinput's // CharLimit is wired from maxPRDNameLength and does not drift to a hard-coded // value. Changing the constant must be the only change needed to adjust the @@ -646,11 +675,12 @@ func TestUS006_InvalidMultiByteRunesSilentlyRejected(t *testing.T) { } // TestUS006_PasteMyFeatureV2: pasting "my feature/v2!" into an empty field -// yields "myfeaturev2". +// yields "my-feature-v2" — interior runs of invalid characters collapse to +// a single '-' and trailing invalid characters are stripped. func TestUS006_PasteMyFeatureV2(t *testing.T) { f := newPRDNameSetup(t, "") f = updateKey(t, f, pasteMsg("my feature/v2!")) - if got, want := f.ti.Value(), "myfeaturev2"; got != want { + if got, want := f.ti.Value(), "my-feature-v2"; got != want { t.Fatalf("paste 'my feature/v2!': got %q, want %q", got, want) } } diff --git a/internal/tui/input_filters.go b/internal/tui/input_filters.go index 30d2f185..f2ab83f5 100644 --- a/internal/tui/input_filters.go +++ b/internal/tui/input_filters.go @@ -11,6 +11,17 @@ func isTextualKey(msg tea.KeyMsg) bool { return msg.Type == tea.KeyRunes || msg.Type == tea.KeySpace } +// isPasteLike reports whether a KeyMsg should be treated as a paste for the +// purposes of input normalization. A bracketed-paste event sets Paste=true +// explicitly; terminals that do not advertise bracketed paste still deliver +// the clipboard as a single multi-rune KeyRunes message, which we treat as +// a paste too so behavior stays consistent across terminals. Single-rune +// input (keystrokes) always goes through the drop filter instead — there is +// no "run" of invalid chars to collapse in that case. +func isPasteLike(msg tea.KeyMsg) bool { + return msg.Paste || len(msg.Runes) > 1 +} + // prdNameSeparators are the word-separator runes used by PRD-name editors // (both FirstTimeSetup's StepPRDName and PRDPicker's new-PRD-name input) for // Ctrl+Left/Right word jumps. Defined once so the two widgets can't drift. @@ -20,34 +31,87 @@ var prdNameSeparators = []rune{'-', '_'} // branch-name editor for Ctrl+Left/Right word jumps. var branchNameSeparators = []rune{'-', '_', '/'} +// isAllowedPRDNameRune reports whether r is in the PRD-name charset +// ([a-zA-Z0-9_-]). +func isAllowedPRDNameRune(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' +} + +// isAllowedBranchNameRune reports whether r is in the branch-name charset +// ([a-zA-Z0-9_/-]). +func isAllowedBranchNameRune(r rune) bool { + return isAllowedPRDNameRune(r) || r == '/' +} + // filterPRDNameRunes drops any rune outside the allowed PRD-name character // set ([a-zA-Z0-9_-]). Returns a new slice so the caller can safely forward // the filtered KeyMsg to the textinput. func filterPRDNameRunes(runes []rune) []rune { - filtered := make([]rune, 0, len(runes)) - for _, r := range runes { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '-' || r == '_' { - filtered = append(filtered, r) - } - } - return filtered + return filterRunes(runes, isAllowedPRDNameRune) } // filterBranchNameRunes drops any rune outside the allowed branch-name // character set ([a-zA-Z0-9_/-]). Returns a new slice so the caller can safely // forward the filtered KeyMsg to the textinput. func filterBranchNameRunes(runes []rune) []rune { + return filterRunes(runes, isAllowedBranchNameRune) +} + +func filterRunes(runes []rune, allowed func(rune) bool) []rune { filtered := make([]rune, 0, len(runes)) for _, r := range runes { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '/' { + if allowed(r) { filtered = append(filtered, r) } } return filtered } +// normalizePastedRunes is the paste-time counterpart of filterRunes: instead +// of silently dropping disallowed runes, it replaces any interior run of +// disallowed runes with a single '-' and strips disallowed runes at the start +// and end. Consecutive '-' runes (either already present in the paste or +// introduced by the replacement) collapse to a single '-' so the result never +// contains "--". Normalization is scoped to the pasted content — adjacency +// with runes already in the target field is intentionally not considered. +func normalizePastedRunes(runes []rune, allowed func(rune) bool) []rune { + start := 0 + for start < len(runes) && !allowed(runes[start]) { + start++ + } + end := len(runes) + for end > start && !allowed(runes[end-1]) { + end-- + } + + out := make([]rune, 0, end-start) + pendingDash := false + for i := start; i < end; i++ { + r := runes[i] + if !allowed(r) { + pendingDash = true + continue + } + if r == '-' { + pendingDash = false + if len(out) > 0 && out[len(out)-1] == '-' { + continue + } + out = append(out, '-') + continue + } + if pendingDash { + if len(out) == 0 || out[len(out)-1] != '-' { + out = append(out, '-') + } + pendingDash = false + } + out = append(out, r) + } + return out +} + // wordBackward returns the caret position after a word-jump-left from pos, // treating any rune in seps as a word separator. Mirrors bubbles' // wordBackward structure (skip separators, then skip non-separators) so diff --git a/internal/tui/input_filters_test.go b/internal/tui/input_filters_test.go new file mode 100644 index 00000000..94018250 --- /dev/null +++ b/internal/tui/input_filters_test.go @@ -0,0 +1,92 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// TestIsPasteLike locks the exact fallback contract: a KeyMsg is treated as +// a paste iff it has Paste=true (bracketed-paste path) OR it carries more +// than one rune in a single KeyRunes event (terminals without bracketed +// paste deliver pasted clipboard content this way). Single-rune keystrokes +// must NOT be treated as pastes — otherwise invalid single-char input would +// be silently stripped instead of dropped, and the three widgets would +// diverge from their typing-path semantics. +func TestIsPasteLike(t *testing.T) { + cases := []struct { + name string + msg tea.KeyMsg + want bool + }{ + {"single rune typing", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, false}, + {"single invalid rune typing", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}}, false}, + {"space keystroke", tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}, false}, + {"bracketed paste single rune", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}, Paste: true}, true}, + {"bracketed paste multi rune", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("foo bar"), Paste: true}, true}, + {"non-bracketed multi-rune fallback", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("foo bar")}, true}, + {"two-rune paste fallback", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a', 'b'}}, true}, + {"empty runes", tea.KeyMsg{Type: tea.KeyRunes, Runes: nil}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isPasteLike(tc.msg); got != tc.want { + t.Fatalf("isPasteLike(%+v) = %v, want %v", tc.msg, got, tc.want) + } + }) + } +} + +func TestNormalizePastedRunes_PRDName(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"all valid unchanged", "my-feature_v2", "my-feature_v2"}, + {"interior run collapses to dash", "my feature v2", "my-feature-v2"}, + {"mixed invalid run collapses", "my f@@!v2", "my-f-v2"}, + {"slash collapses for PRD charset", "feat/oops v2", "feat-oops-v2"}, + {"trailing invalid stripped", "foo!", "foo"}, + {"leading invalid stripped", "!foo", "foo"}, + {"both ends stripped", "!!foo!!", "foo"}, + {"double dash collapses", "foo--bar", "foo-bar"}, + {"dash plus invalid collapses", "foo-@-bar", "foo-bar"}, + {"invalid plus dash collapses", "foo@-bar", "foo-bar"}, + {"long invalid run collapses to one dash", "foo@@@@bar", "foo-bar"}, + {"all invalid yields empty", "!@#$", ""}, + {"empty input yields empty", "", ""}, + {"leading dash preserved", "-foo", "-foo"}, + {"trailing dash preserved", "foo-", "foo-"}, + {"stripped invalid then dash then invalid then valid", "!-@foo", "-foo"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := string(normalizePastedRunes([]rune(tc.in), isAllowedPRDNameRune)) + if got != tc.want { + t.Fatalf("normalizePastedRunes(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizePastedRunes_BranchName(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"slash preserved", "feat/oops", "feat/oops"}, + {"slash preserved around invalid run", "feat/oops bad", "feat/oops-bad"}, + {"trailing bang stripped preserves slash", "feat/oops bad!", "feat/oops-bad"}, + {"unicode stripped, interior run collapses", "féature/v2", "f-ature/v2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := string(normalizePastedRunes([]rune(tc.in), isAllowedBranchNameRune)) + if got != tc.want { + t.Fatalf("normalizePastedRunes(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 679ae154..b7ada9f8 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -338,7 +338,11 @@ func (p *PRDPicker) UpdateInput(msg tea.KeyMsg) tea.Cmd { } if isTextualKey(msg) { - msg.Runes = filterPRDNameRunes(msg.Runes) + if isPasteLike(msg) { + msg.Runes = normalizePastedRunes(msg.Runes, isAllowedPRDNameRune) + } else { + msg.Runes = filterPRDNameRunes(msg.Runes) + } } var cmd tea.Cmd diff --git a/internal/tui/picker_input_test.go b/internal/tui/picker_input_test.go index 17170754..ccb556e2 100644 --- a/internal/tui/picker_input_test.go +++ b/internal/tui/picker_input_test.go @@ -162,11 +162,13 @@ func TestPickerInput_SpaceKeyIsFiltered(t *testing.T) { } } -// TestPickerInput_PasteFiltersInvalidChars: paste "my feature/v2!" → "myfeaturev2". +// TestPickerInput_PasteFiltersInvalidChars: paste "my feature/v2!" → +// "my-feature-v2" (interior invalid runs collapsed to '-', trailing '!' +// stripped). func TestPickerInput_PasteFiltersInvalidChars(t *testing.T) { p := newPickerInputMode(t, "") sendPickerKey(t, p, pasteMsg("my feature/v2!")) - if got, want := p.ti.Value(), "myfeaturev2"; got != want { + if got, want := p.ti.Value(), "my-feature-v2"; got != want { t.Fatalf("paste filtered: got %q, want %q", got, want) } } @@ -182,6 +184,39 @@ func TestPickerInput_PasteTripleMaxLengthTruncates(t *testing.T) { } } +// TestPickerInput_PasteCollapsesInteriorRunAndStripsEnds exercises the full +// paste normalization rule for the picker's new-PRD-name input. +func TestPickerInput_PasteCollapsesInteriorRunAndStripsEnds(t *testing.T) { + p := newPickerInputMode(t, "") + sendPickerKey(t, p, pasteMsg("!!foo---@@bar!!")) + if got, want := p.ti.Value(), "foo-bar"; got != want { + t.Fatalf("normalized paste: got %q, want %q", got, want) + } +} + +// TestPickerInput_PasteAllInvalidIsNoOp verifies that an all-invalid paste +// normalizes to an empty slice and leaves the value unchanged. +func TestPickerInput_PasteAllInvalidIsNoOp(t *testing.T) { + p := newPickerInputMode(t, "main") + sendPickerKey(t, p, pasteMsg("! @ # $")) + if got, want := p.ti.Value(), "main"; got != want { + t.Fatalf("all-invalid paste should not change value: got %q, want %q", got, want) + } +} + +// TestPickerInput_PasteWithoutBracketedFlagAlsoNormalized mirrors the +// FirstTimeSetup coverage at the picker widget: a multi-rune KeyRunes event +// without Paste=true (terminals without bracketed paste) is treated as a +// paste and normalized — runs of invalid chars collapse to '-', trailing +// invalid chars are stripped. +func TestPickerInput_PasteWithoutBracketedFlagAlsoNormalized(t *testing.T) { + p := newPickerInputMode(t, "") + sendPickerKey(t, p, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ab/cd!")}) + if got, want := p.ti.Value(), "ab-cd"; got != want { + t.Fatalf("non-bracketed multi-rune paste: got %q, want %q", got, want) + } +} + // TestPickerInput_TypingAtMaxLengthIsSilentNoOp: once at max length, typing // any further allowed character is silently dropped (value unchanged, cursor // unchanged). @@ -299,13 +334,11 @@ func managerWithRunningPRD(t *testing.T, name string) *loop.Manager { if err := m.Register(name, "/tmp/"+name); err != nil { t.Fatalf("Register: %v", err) } - inst := m.GetInstance(name) - if inst == nil { - t.Fatal("registered instance not retrievable") + // GetInstance returns a copy, so mutating its fields would not affect the + // manager's internal instance. Use the dedicated test-oriented setter. + if !m.SetInstanceState(name, loop.LoopStateRunning) { + t.Fatalf("SetInstanceState: instance %q not found after Register", name) } - // Single-goroutine test — no concurrent reader, so writing State - // directly is safe without locking instance.mu. - inst.State = loop.LoopStateRunning return m }