diff --git a/.gitignore b/.gitignore index bdae7dd..50ce85d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.test *.out /tmp +/.claude/ diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/memories/conventions.md b/.serena/memories/conventions.md new file mode 100644 index 0000000..943bbde --- /dev/null +++ b/.serena/memories/conventions.md @@ -0,0 +1,6 @@ +# Conventions + +- Keep package boundaries narrow: CLI branching in `cmd/shp`, path-to-name logic in `internal/session`, shpool process/list parsing in `internal/shpool`, picker state/rendering in `internal/tui`. +- Session names are cwd/home-relative path strings with `/` mapped to `.`, only `[A-Za-z0-9._-]` preserved, other chars mapped to `_`, and short hash suffix added when needed for collision mitigation. +- Attach always uses `shpool attach [ -f ] --dir . ` so newly-created sessions start in the current directory. +- TUI filtering is case-insensitive substring AND over whitespace-separated tokens; Backspace deletes by rune; default cwd item is prepended/deduped and labeled `(cwd)`. diff --git a/.serena/memories/core.md b/.serena/memories/core.md new file mode 100644 index 0000000..792f308 --- /dev/null +++ b/.serena/memories/core.md @@ -0,0 +1,6 @@ +# Core + +- Go CLI wrapping `shpool attach`; executable entrypoint `cmd/shp/main.go`. +- Packages: `internal/session` derives session names from cwd/path; `internal/shpool` wraps `shpool` CLI list/attach; `internal/tui` is the Bubble Tea picker. +- `shp` with no args shows TUI using cwd-derived default plus existing `shpool list` sessions; explicit names bypass TUI and attach directly. +- Read `mem:tech_stack` for tools/deps, `mem:conventions` for implementation style, `mem:suggested_commands` for local commands, `mem:task_completion` before finishing coding tasks. diff --git a/.serena/memories/memory_maintenance.md b/.serena/memories/memory_maintenance.md new file mode 100644 index 0000000..3dcf364 --- /dev/null +++ b/.serena/memories/memory_maintenance.md @@ -0,0 +1,33 @@ +# Memory Maintenance + +## Discovery Model + +- Core principle: progressive discovery through references, building a graph of memories. +- Initially, agents are provided with the list of all memories (names only). +- Agents should read `mem:core` as the top-level entry point (graph root). + This memory should contain references to other memories covering major project domains. + The referenced memories shall, in turn, contain references to even more specific memories, and so on. + The depth of the graph shall depend on the project complexity. +- Use topics/folders to group related memories in order to make the content structure explicit. + Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc. +- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`. + The surrounding text should clearly indicate when to read the memory/which content to expect. + The text should provide more precise guidance than the memory name alone, + i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered. +- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory. + +## Style + +Dense agent notes, not prose docs. Prefer invariants, terse bullets. +Avoid obvious context, rationale, and examples unless they prevent likely mistakes. +Keep guidance durable and generalizable, not task-local. + +## Add/update threshold + +Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future. +Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon. + +## Maintenance Actions + +- Renaming memories: References are updated automatically if handled via Serena's memory rename tool. +- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report. diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 0000000..37403e5 --- /dev/null +++ b/.serena/memories/suggested_commands.md @@ -0,0 +1,7 @@ +# Suggested Commands + +- `mise install` installs the pinned Go toolchain. +- `mise run build` builds `./bin/shp`; equivalent: `go build -o ./bin/shp ./cmd/shp`. +- `mise run test` runs `go test ./...`; `mise run vet` runs `go vet ./...`; `mise run check` runs both. +- `go run ./cmd/shp --print-name` exercises session-name generation without requiring `shpool` runtime behavior. +- `go run ./cmd/shp --help` checks CLI usage text. diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md new file mode 100644 index 0000000..9693fd4 --- /dev/null +++ b/.serena/memories/task_completion.md @@ -0,0 +1,6 @@ +# Task Completion + +- Run `gofmt` on changed Go files. +- Run `go test ./...` for coding changes. +- Run `go vet ./...` when behavior crosses package boundaries or before release-level changes; `mise run check` covers vet + tests. +- For TUI behavior changes, add/adjust unit tests in `internal/tui/select_test.go`; avoid relying only on manual interactive checks. diff --git a/.serena/memories/tech_stack.md b/.serena/memories/tech_stack.md new file mode 100644 index 0000000..a657be1 --- /dev/null +++ b/.serena/memories/tech_stack.md @@ -0,0 +1,5 @@ +# Tech Stack + +- Go module `github.com/uzulla/shpool-launch`; Go version pinned in `go.mod` as `go 1.25.10`, mise pins `go = "1.25"`. +- Dependencies are intentionally small: Bubble Tea v1 and Lipgloss for TUI; standard library `flag` for CLI parsing; `syscall.Exec` for attach handoff. +- Build/install/test tasks are defined in `mise.toml`; plain `go` commands also work. diff --git a/DEV.md b/DEV.md index 98d1c8c..1ddb897 100644 --- a/DEV.md +++ b/DEV.md @@ -128,6 +128,12 @@ attach には常に `--dir .` を渡す。既存セッションへの attach で - 上部に `QUERY> ...` を表示し、下にフィルタ済み候補 - フィルタ: case-insensitive substring AND (`Filter(items, query)` として独立、テスト対象) - キー操作: 文字入力で絞り込み、Backspace、↑ / Ctrl-P、↓ / Ctrl-N、Enter、Esc / Ctrl-C +- 絞り込み結果が0件の Enter は新規セッション名として query を返す(`newSessionName`): + - `strings.TrimSpace` で前後空白を落とす。トリム後に内部空白が残る query は作成しない + - ダッシュのみ(`-`, `--` 等)の query は作成しない + - query が `-` で始まり cwd 既定項目がある場合は `defaultItem + query`(例: cwd が `work.company-a.api` で query が `-another` なら `work.company-a.api-another`)。ただし結合結果が `-` 始まりになる(既定名自体が `-` 始まり)場合は作成しない。cwd 既定項目がなければ作成しない + - 返す名前は `session.Sanitize` で cwd 由来名と同じ文字集合 (`[A-Za-z0-9._-]`、それ以外は `_`) に正規化する + - 表示ラベルは、実在セッション(`shpool list`)と一致すれば `(existing)`、合成された cwd 既定候補を含め未起動なら `(new)` - Backspace は rune 単位で削除する - 候補が端末高に収まらない場合はカーソル位置に合わせてスクロールする - `SelectWithDefault(items, defaultItem)` で「先頭に置く既定項目」を渡せる。既定項目は `(cwd)` ラベル付きで表示され、初期カーソル位置になる。既存リスト内に重複があれば dedupe。 diff --git a/README.md b/README.md index 912ecae..581a64d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ QUERY> ``` - Enter でカーソル位置に attach (該当セッションが無ければ `shpool attach --dir .` で現在のディレクトリに新規作成) +- 絞り込み結果が0件の状態で Enter すると、入力した文字列で新規セッションを作成する: + - 前後の空白はトリムする。トリム後に内部空白が残る入力(例: `new session`)は作成しない + - cwd 候補がある状態で `-another` のように `-` から入力した場合は、cwd 候補にサフィックスとして足して `work.company-a.api-another` のように作成する + - cwd 候補が無い状態で `-scratch` のように `-` から入力した場合は、オプションと誤認されうるため作成しない + - セッション名に使えない文字 (`[A-Za-z0-9._-]` 以外) は cwd 由来名と同じく `_` に置き換える + - 生成名が既存セッションと一致する場合は `(existing)`、しない場合は `(new)` と表示する - 文字を入力すれば case-insensitive substring AND で絞り込める - 絞り込みは case-insensitive の substring AND マッチ。`company api` のように空白で AND 検索できる。 - 候補が画面に収まらない場合はカーソル移動に合わせてスクロールする。 diff --git a/cmd/shp/main.go b/cmd/shp/main.go index 7fbbc2f..9e1a382 100644 --- a/cmd/shp/main.go +++ b/cmd/shp/main.go @@ -19,7 +19,8 @@ const usage = `shp - shpool attach helper Usage: shp Show a TUI picker. The cwd-derived session name is the default selection; existing sessions follow - when available. Enter attaches. + when available. Enter attaches or creates when no + match is found. shp Attach to the given session name (no TUI). shp -f Force-attach to a session named after the current directory (no TUI). diff --git a/internal/session/name.go b/internal/session/name.go index d72d9d1..3798644 100644 --- a/internal/session/name.go +++ b/internal/session/name.go @@ -45,9 +45,22 @@ func FromPath(path, home string) string { needsSuffix := needsHashSuffix(rel) nameInput := strings.ReplaceAll(rel, "/", ".") + name := Sanitize(nameInput) + if needsSuffix { + name += "-" + shortHash(rel) + } + return name +} + +// Sanitize maps a string to the shpool session-name character set used +// throughout this tool: only [A-Za-z0-9._-] are preserved and every other +// rune becomes '_'. It is the single normalization rule that both +// path-derived names and free-text (TUI) names go through, so that all +// session names share one safe alphabet. +func Sanitize(s string) string { var b strings.Builder - b.Grow(len(nameInput) + 9) - for _, r := range nameInput { + b.Grow(len(s)) + for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', @@ -58,10 +71,6 @@ func FromPath(path, home string) string { b.WriteRune('_') } } - if needsSuffix { - b.WriteByte('-') - b.WriteString(shortHash(rel)) - } return b.String() } diff --git a/internal/shpool/attach.go b/internal/shpool/attach.go index 0e3a8d5..a6beb57 100644 --- a/internal/shpool/attach.go +++ b/internal/shpool/attach.go @@ -45,6 +45,8 @@ func attachArgs(name string, force bool) []string { args = append(args, "-f") } args = append(args, "--dir", ".") - args = append(args, name) + // Terminate option parsing with "--" so a session name that begins with + // "-" is always treated as the positional , never as a flag. + args = append(args, "--", name) return args } diff --git a/internal/shpool/attach_test.go b/internal/shpool/attach_test.go index 674912e..a9cd20b 100644 --- a/internal/shpool/attach_test.go +++ b/internal/shpool/attach_test.go @@ -16,13 +16,19 @@ func TestAttachArgs(t *testing.T) { test: "normal", name: "my-session", force: false, - want: []string{"shpool", "attach", "--dir", ".", "my-session"}, + want: []string{"shpool", "attach", "--dir", ".", "--", "my-session"}, }, { test: "force", name: "my-session", force: true, - want: []string{"shpool", "attach", "-f", "--dir", ".", "my-session"}, + want: []string{"shpool", "attach", "-f", "--dir", ".", "--", "my-session"}, + }, + { + test: "dash-leading name stays positional after --", + name: "-repo-another", + force: false, + want: []string{"shpool", "attach", "--dir", ".", "--", "-repo-another"}, }, } for _, tc := range cases { diff --git a/internal/tui/select.go b/internal/tui/select.go index 7b2dc46..7b112bf 100644 --- a/internal/tui/select.go +++ b/internal/tui/select.go @@ -6,9 +6,12 @@ import ( "errors" "fmt" "strings" + "unicode" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + + "github.com/uzulla/shpool-launch/internal/session" ) // ErrCancelled is returned when the user dismisses the UI (Esc / Ctrl-C). @@ -31,6 +34,7 @@ func SelectWithDefault(items []string, defaultItem string) (string, error) { } m := newModel(all) m.defaultItem = defaultItem + m.sessions = append([]string(nil), items...) p := tea.NewProgram(m, tea.WithAltScreen()) final, err := p.Run() if err != nil { @@ -105,6 +109,10 @@ type model struct { selected string cancelled bool defaultItem string + // sessions is the set of real `shpool list` sessions, excluding the + // synthetic cwd default that orderWithDefault injects into `all`. It is + // what distinguishes an existing session from a to-be-created one. + sessions []string } func newModel(items []string) model { @@ -129,9 +137,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancelled = true return m, tea.Quit case tea.KeyEnter: - if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { - m.selected = m.filtered[m.cursor] + selected := m.currentSelection() + if selected == "" { + return m, nil } + m.selected = selected return m, tea.Quit case tea.KeyUp, tea.KeyCtrlP: if m.cursor > 0 { @@ -178,6 +188,65 @@ func (m *model) refilter() { m.ensureCursorVisible() } +func (m model) currentSelection() string { + if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { + return m.filtered[m.cursor] + } + name, ok := newSessionName(m.defaultItem, m.query) + if !ok { + return "" + } + return name +} + +// isExistingSession reports whether name is a real `shpool list` session, +// not merely the synthetic cwd default injected for display. +func (m model) isExistingSession(name string) bool { + for _, it := range m.sessions { + if it == name { + return true + } + } + return false +} + +func newSessionName(defaultItem, query string) (string, bool) { + name := strings.TrimSpace(query) + if name == "" { + return "", false + } + if containsSpace(name) { + return "", false + } + if strings.Trim(name, "-") == "" { + // Dash-only input ("-", "--", ...) is not a meaningful name. + return "", false + } + if defaultItem != "" && strings.HasPrefix(name, "-") { + candidate := session.Sanitize(defaultItem + name) + // Reject when defaultItem itself starts with "-" (e.g. a "~/-repo" + // cwd): the combined name would lead with "-" and be misread as an + // option by `shpool attach`. + if strings.HasPrefix(candidate, "-") { + return "", false + } + return candidate, true + } + if strings.HasPrefix(name, "-") { + return "", false + } + return session.Sanitize(name), true +} + +func containsSpace(s string) bool { + for _, r := range s { + if unicode.IsSpace(r) { + return true + } + } + return false +} + func dropLastRune(s string) string { rs := []rune(s) return string(rs[:len(rs)-1]) @@ -239,7 +308,23 @@ func (m model) View() string { b.WriteString("\n\n") if len(m.filtered) == 0 { - b.WriteString(dimStyle.Render(" (no matches)")) + if name, ok := newSessionName(m.defaultItem, m.query); ok { + b.WriteString(cursorStyle.Render("> ")) + b.WriteString(selectedStyle.Render(name)) + b.WriteString(" ") + // The normalized name can collide with an existing session (e.g. + // query "foo/bar" sanitizes to an existing "foo_bar"); label it + // honestly so the user knows Enter reattaches, not creates. + if m.isExistingSession(name) { + b.WriteString(dimStyle.Render("(existing)")) + } else { + b.WriteString(dimStyle.Render("(new)")) + } + } else if strings.TrimSpace(m.query) != "" { + b.WriteString(dimStyle.Render(" (invalid session name)")) + } else { + b.WriteString(dimStyle.Render(" (no matches)")) + } b.WriteString("\n") } start, end := m.visibleRange() @@ -259,7 +344,7 @@ func (m model) View() string { b.WriteString("\n") } b.WriteString("\n") - b.WriteString(dimStyle.Render("Enter: attach Esc/Ctrl-C: cancel ↑/↓ or Ctrl-P/N: move")) + b.WriteString(dimStyle.Render("Enter: attach/create Esc/Ctrl-C: cancel ↑/↓ or Ctrl-P/N: move")) b.WriteString("\n") return b.String() } diff --git a/internal/tui/select_test.go b/internal/tui/select_test.go index 33944f6..c5eb948 100644 --- a/internal/tui/select_test.go +++ b/internal/tui/select_test.go @@ -2,11 +2,21 @@ package tui import ( "reflect" + "strings" "testing" tea "github.com/charmbracelet/bubbletea" ) +func asModel(t *testing.T, updated tea.Model) model { + t.Helper() + m, ok := updated.(model) + if !ok { + t.Fatalf("Update returned %T, want model", updated) + } + return m +} + func TestOrderWithDefault(t *testing.T) { cases := []struct { name string @@ -69,21 +79,238 @@ func TestModelBackspaceRemovesWholeRune(t *testing.T) { m.query = "あい" updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) - got := updated.(model).query + got := asModel(t, updated).query want := "あ" if got != want { t.Fatalf("query = %q, want %q", got, want) } } +func TestModelEnterSelectsHighlightedMatch(t *testing.T) { + m := newModel([]string{"alpha", "beta"}) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = asModel(t, updated) + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + want := "beta" + if got != want { + t.Fatalf("selected = %q, want %q", got, want) + } + if cmd == nil { + t.Fatalf("cmd = nil, want quit command") + } +} + +func TestModelEnterCreatesTypedNameWhenNoMatch(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.query = "new-session" + m.refilter() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + want := "new-session" + if got != want { + t.Fatalf("selected = %q, want %q", got, want) + } + if cmd == nil { + t.Fatalf("cmd = nil, want quit command") + } +} + +func TestModelEnterCreatesDefaultSuffixWhenNoMatch(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.defaultItem = "dev.shp" + m.query = "-another" + m.refilter() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + want := "dev.shp-another" + if got != want { + t.Fatalf("selected = %q, want %q", got, want) + } + if cmd == nil { + t.Fatalf("cmd = nil, want quit command") + } +} + +func TestModelEnterRejectsDefaultSuffixWithWhitespace(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.defaultItem = "dev.shp" + m.query = "-another name" + m.refilter() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + if got != "" { + t.Fatalf("selected = %q, want empty", got) + } + if cmd != nil { + t.Fatalf("cmd = %v, want nil", cmd) + } +} + +func TestModelViewShowsNewDefaultSuffixCandidate(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.defaultItem = "dev.shp" + m.query = "-another" + m.refilter() + + got := m.View() + if !strings.Contains(got, "dev.shp-another") { + t.Fatalf("View() = %q, want new session candidate", got) + } +} + +func TestModelEnterRejectsNewNameWithWhitespace(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.query = "new session" + m.refilter() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + if got != "" { + t.Fatalf("selected = %q, want empty", got) + } + if cmd != nil { + t.Fatalf("cmd = %v, want nil", cmd) + } +} + +func TestModelEnterRejectsNewNameWithFullWidthSpace(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.query = "新規 セッション" // contains a full-width space (U+3000) + m.refilter() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + if got != "" { + t.Fatalf("selected = %q, want empty", got) + } + if cmd != nil { + t.Fatalf("cmd = %v, want nil", cmd) + } +} + +func TestModelEnterRejectsDashOnlyNewName(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.defaultItem = "dev.shp" + m.query = "--" + m.refilter() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + if got != "" { + t.Fatalf("selected = %q, want empty", got) + } + if cmd != nil { + t.Fatalf("cmd = %v, want nil", cmd) + } +} + +func TestModelEnterRejectsSuffixWhenDefaultLeadsWithDash(t *testing.T) { + m := newModel([]string{"-repo"}) + m.defaultItem = "-repo" // e.g. derived from a "~/-repo" cwd + m.query = "-another" + m.refilter() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + if got != "" { + t.Fatalf("selected = %q, want empty (dash-leading name must not be created)", got) + } + if cmd != nil { + t.Fatalf("cmd = %v, want nil", cmd) + } +} + +func TestModelEnterNormalizesNewNameToSafeCharset(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.query = "foo/bar" + m.refilter() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + want := "foo_bar" + if got != want { + t.Fatalf("selected = %q, want %q", got, want) + } + if cmd == nil { + t.Fatalf("cmd = nil, want quit command") + } +} + +func TestModelViewLabelsSanitizedCollisionAsExisting(t *testing.T) { + m := newModel([]string{"foo_bar", "dev.shp"}) + m.sessions = []string{"foo_bar", "dev.shp"} // real `shpool list` sessions + m.query = "foo/bar" // sanitizes to the existing "foo_bar" + m.refilter() + + got := m.View() + if !strings.Contains(got, "foo_bar") { + t.Fatalf("View() = %q, want candidate foo_bar", got) + } + if strings.Contains(got, "(new)") { + t.Fatalf("View() = %q, must not label an existing session as (new)", got) + } + if !strings.Contains(got, "(existing)") { + t.Fatalf("View() = %q, want (existing) label", got) + } +} + +func TestModelViewLabelsSyntheticDefaultCollisionAsNew(t *testing.T) { + // defaultItem is the cwd-derived name but is NOT a running session, so a + // query that sanitizes to it must be labeled (new): Enter creates it. + m := newModel([]string{"foo_bar", "dev.shp"}) + m.defaultItem = "foo_bar" + m.sessions = []string{"dev.shp"} // foo_bar is the injected default, not real + m.query = "foo/bar" + m.refilter() + + got := m.View() + if strings.Contains(got, "(existing)") { + t.Fatalf("View() = %q, must not label a non-running default as (existing)", got) + } + if !strings.Contains(got, "(new)") { + t.Fatalf("View() = %q, want (new) label", got) + } +} + +func TestModelEnterRejectsLeadingDashWithoutDefault(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.query = "-scratch" + m.refilter() + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := asModel(t, updated).selected + if got != "" { + t.Fatalf("selected = %q, want empty", got) + } + if cmd != nil { + t.Fatalf("cmd = %v, want nil", cmd) + } +} + +func TestModelViewShowsInvalidNewSessionName(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.query = "new session" + m.refilter() + + got := m.View() + if !strings.Contains(got, "(invalid session name)") { + t.Fatalf("View() = %q, want invalid session message", got) + } +} + func TestModelScrollsCursorIntoVisibleRange(t *testing.T) { m := newModel([]string{"a", "b", "c", "d", "e"}) updated, _ := m.Update(tea.WindowSizeMsg{Height: 7}) - m = updated.(model) + m = asModel(t, updated) for i := 0; i < 4; i++ { updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(model) + m = asModel(t, updated) } if m.cursor != 4 {