From 5b7c55ec1739d65f0c512f6c0a91d8a070461cc8 Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 11:10:47 +0900 Subject: [PATCH 01/10] create sessions from unmatched picker input --- DEV.md | 1 + README.md | 1 + cmd/shp/main.go | 3 ++- internal/tui/select.go | 33 +++++++++++++++++++---- internal/tui/select_test.go | 53 +++++++++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/DEV.md b/DEV.md index 98d1c8c..4cb4e3b 100644 --- a/DEV.md +++ b/DEV.md @@ -128,6 +128,7 @@ attach には常に `--dir .` を渡す。既存セッションへの attach で - 上部に `QUERY> ...` を表示し、下にフィルタ済み候補 - フィルタ: case-insensitive substring AND (`Filter(items, query)` として独立、テスト対象) - キー操作: 文字入力で絞り込み、Backspace、↑ / Ctrl-P、↓ / Ctrl-N、Enter、Esc / Ctrl-C +- 絞り込み結果が0件の Enter は新規セッション名として query を返す。query が `-` で始まり cwd 既定項目がある場合は `defaultItem + query` を返す。 - Backspace は rune 単位で削除する - 候補が端末高に収まらない場合はカーソル位置に合わせてスクロールする - `SelectWithDefault(items, defaultItem)` で「先頭に置く既定項目」を渡せる。既定項目は `(cwd)` ラベル付きで表示され、初期カーソル位置になる。既存リスト内に重複があれば dedupe。 diff --git a/README.md b/README.md index 912ecae..407ea3d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ QUERY> ``` - Enter でカーソル位置に attach (該当セッションが無ければ `shpool attach --dir .` で現在のディレクトリに新規作成) +- 絞り込み結果が0件の状態で Enter すると、入力した文字列で新規セッションを作成する。cwd 候補がある状態で `-another` のように `-` から入力した場合は、cwd 候補にサフィックスとして足して `work.company-a.api-another` のように作成する。 - 文字を入力すれば 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/tui/select.go b/internal/tui/select.go index 7b2dc46..33f4e85 100644 --- a/internal/tui/select.go +++ b/internal/tui/select.go @@ -129,9 +129,7 @@ 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] - } + m.selected = m.currentSelection() return m, tea.Quit case tea.KeyUp, tea.KeyCtrlP: if m.cursor > 0 { @@ -178,6 +176,24 @@ 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] + } + return newSessionName(m.defaultItem, m.query) +} + +func newSessionName(defaultItem, query string) string { + name := strings.TrimSpace(query) + if name == "" { + return "" + } + if defaultItem != "" && strings.HasPrefix(name, "-") { + return defaultItem + name + } + return name +} + func dropLastRune(s string) string { rs := []rune(s) return string(rs[:len(rs)-1]) @@ -239,7 +255,14 @@ func (m model) View() string { b.WriteString("\n\n") if len(m.filtered) == 0 { - b.WriteString(dimStyle.Render(" (no matches)")) + if name := newSessionName(m.defaultItem, m.query); name != "" { + b.WriteString(cursorStyle.Render("> ")) + b.WriteString(selectedStyle.Render(name)) + b.WriteString(" ") + b.WriteString(dimStyle.Render("(new)")) + } else { + b.WriteString(dimStyle.Render(" (no matches)")) + } b.WriteString("\n") } start, end := m.visibleRange() @@ -259,7 +282,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..3d09729 100644 --- a/internal/tui/select_test.go +++ b/internal/tui/select_test.go @@ -2,6 +2,7 @@ package tui import ( "reflect" + "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -76,6 +77,58 @@ func TestModelBackspaceRemovesWholeRune(t *testing.T) { } } +func TestModelEnterSelectsHighlightedMatch(t *testing.T) { + m := newModel([]string{"alpha", "beta"}) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(model) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := updated.(model).selected + want := "beta" + if got != want { + t.Fatalf("selected = %q, want %q", got, want) + } +} + +func TestModelEnterCreatesTypedNameWhenNoMatch(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.query = "new-session" + m.refilter() + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := updated.(model).selected + want := "new-session" + if got != want { + t.Fatalf("selected = %q, want %q", got, want) + } +} + +func TestModelEnterCreatesDefaultSuffixWhenNoMatch(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.defaultItem = "dev.shp" + m.query = "-another" + m.refilter() + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + got := updated.(model).selected + want := "dev.shp-another" + if got != want { + t.Fatalf("selected = %q, want %q", got, want) + } +} + +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 TestModelScrollsCursorIntoVisibleRange(t *testing.T) { m := newModel([]string{"a", "b", "c", "d", "e"}) From 62f2bda4ddad93f25a7547361018faf93b9c2ace Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 11:54:24 +0900 Subject: [PATCH 02/10] guard invalid picker-created session names --- DEV.md | 2 +- README.md | 2 +- internal/tui/select.go | 37 ++++++++++++++++++++++++++------- internal/tui/select_test.go | 41 +++++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/DEV.md b/DEV.md index 4cb4e3b..9d64312 100644 --- a/DEV.md +++ b/DEV.md @@ -128,7 +128,7 @@ attach には常に `--dir .` を渡す。既存セッションへの attach で - 上部に `QUERY> ...` を表示し、下にフィルタ済み候補 - フィルタ: case-insensitive substring AND (`Filter(items, query)` として独立、テスト対象) - キー操作: 文字入力で絞り込み、Backspace、↑ / Ctrl-P、↓ / Ctrl-N、Enter、Esc / Ctrl-C -- 絞り込み結果が0件の Enter は新規セッション名として query を返す。query が `-` で始まり cwd 既定項目がある場合は `defaultItem + query` を返す。 +- 絞り込み結果が0件の Enter は新規セッション名として query を返す。空白を含む query は作成しない。query が `-` で始まり cwd 既定項目がある場合は `defaultItem + query` を返し、cwd 既定項目がない場合は作成しない。 - Backspace は rune 単位で削除する - 候補が端末高に収まらない場合はカーソル位置に合わせてスクロールする - `SelectWithDefault(items, defaultItem)` で「先頭に置く既定項目」を渡せる。既定項目は `(cwd)` ラベル付きで表示され、初期カーソル位置になる。既存リスト内に重複があれば dedupe。 diff --git a/README.md b/README.md index 407ea3d..3ba9b70 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ QUERY> ``` - Enter でカーソル位置に attach (該当セッションが無ければ `shpool attach --dir .` で現在のディレクトリに新規作成) -- 絞り込み結果が0件の状態で Enter すると、入力した文字列で新規セッションを作成する。cwd 候補がある状態で `-another` のように `-` から入力した場合は、cwd 候補にサフィックスとして足して `work.company-a.api-another` のように作成する。 +- 絞り込み結果が0件の状態で Enter すると、入力した文字列で新規セッションを作成する。空白を含む入力は作成しない。cwd 候補がある状態で `-another` のように `-` から入力した場合は、cwd 候補にサフィックスとして足して `work.company-a.api-another` のように作成する。 - 文字を入力すれば case-insensitive substring AND で絞り込める - 絞り込みは case-insensitive の substring AND マッチ。`company api` のように空白で AND 検索できる。 - 候補が画面に収まらない場合はカーソル移動に合わせてスクロールする。 diff --git a/internal/tui/select.go b/internal/tui/select.go index 33f4e85..aab83fb 100644 --- a/internal/tui/select.go +++ b/internal/tui/select.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "unicode" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -129,7 +130,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancelled = true return m, tea.Quit case tea.KeyEnter: - m.selected = m.currentSelection() + selected := m.currentSelection() + if selected == "" { + return m, nil + } + m.selected = selected return m, tea.Quit case tea.KeyUp, tea.KeyCtrlP: if m.cursor > 0 { @@ -180,18 +185,34 @@ func (m model) currentSelection() string { if len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { return m.filtered[m.cursor] } - return newSessionName(m.defaultItem, m.query) + name, ok := newSessionName(m.defaultItem, m.query) + if !ok { + return "" + } + return name } -func newSessionName(defaultItem, query string) string { +func newSessionName(defaultItem, query string) (string, bool) { name := strings.TrimSpace(query) if name == "" { - return "" + return "", false } if defaultItem != "" && strings.HasPrefix(name, "-") { - return defaultItem + name + return defaultItem + name, true } - return name + if strings.HasPrefix(name, "-") || containsSpace(name) { + return "", false + } + return name, true +} + +func containsSpace(s string) bool { + for _, r := range s { + if unicode.IsSpace(r) { + return true + } + } + return false } func dropLastRune(s string) string { @@ -255,11 +276,13 @@ func (m model) View() string { b.WriteString("\n\n") if len(m.filtered) == 0 { - if name := newSessionName(m.defaultItem, m.query); name != "" { + if name, ok := newSessionName(m.defaultItem, m.query); ok { b.WriteString(cursorStyle.Render("> ")) b.WriteString(selectedStyle.Render(name)) b.WriteString(" ") 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)")) } diff --git a/internal/tui/select_test.go b/internal/tui/select_test.go index 3d09729..70436ad 100644 --- a/internal/tui/select_test.go +++ b/internal/tui/select_test.go @@ -129,6 +129,47 @@ func TestModelViewShowsNewDefaultSuffixCandidate(t *testing.T) { } } +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 := updated.(model).selected + if got != "" { + t.Fatalf("selected = %q, want empty", got) + } + if cmd != nil { + t.Fatalf("cmd = %v, want nil", cmd) + } +} + +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 := updated.(model).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"}) From 365cd927e9c35670b16d618c361a9b88d93786e5 Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 22:01:05 +0900 Subject: [PATCH 03/10] Add documentation for conventions, core structure, memory maintenance, suggested commands, task completion, and tech stack --- .serena/.gitignore | 2 ++ .serena/memories/conventions.md | 6 +++++ .serena/memories/core.md | 6 +++++ .serena/memories/memory_maintenance.md | 33 ++++++++++++++++++++++++++ .serena/memories/suggested_commands.md | 7 ++++++ .serena/memories/task_completion.md | 6 +++++ .serena/memories/tech_stack.md | 5 ++++ 7 files changed, 65 insertions(+) create mode 100644 .serena/.gitignore create mode 100644 .serena/memories/conventions.md create mode 100644 .serena/memories/core.md create mode 100644 .serena/memories/memory_maintenance.md create mode 100644 .serena/memories/suggested_commands.md create mode 100644 .serena/memories/task_completion.md create mode 100644 .serena/memories/tech_stack.md 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..6160441 --- /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)`. \ No newline at end of file diff --git a/.serena/memories/core.md b/.serena/memories/core.md new file mode 100644 index 0000000..2414c82 --- /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. \ No newline at end of file diff --git a/.serena/memories/memory_maintenance.md b/.serena/memories/memory_maintenance.md new file mode 100644 index 0000000..6f84514 --- /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, shall 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. \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md new file mode 100644 index 0000000..2d4aafd --- /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. \ No newline at end of file diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md new file mode 100644 index 0000000..c3636d4 --- /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. \ No newline at end of file diff --git a/.serena/memories/tech_stack.md b/.serena/memories/tech_stack.md new file mode 100644 index 0000000..e67eb84 --- /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. \ No newline at end of file From c86cc1826a004e3bc278a738a4a363ebd86bd784 Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 22:03:52 +0900 Subject: [PATCH 04/10] Address CodeRabbit review on PR #1 - select.go: reject whitespace before applying the default-suffix rule so inputs like "-another name" no longer produce a session name - select_test.go: use ok-checked type assertions via asModel helper - README.md: document that "-scratch" without cwd candidates is rejected --- README.md | 2 +- internal/tui/select.go | 5 ++++- internal/tui/select_test.go | 43 +++++++++++++++++++++++++++++-------- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3ba9b70..3d9016c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ QUERY> ``` - Enter でカーソル位置に attach (該当セッションが無ければ `shpool attach --dir .` で現在のディレクトリに新規作成) -- 絞り込み結果が0件の状態で Enter すると、入力した文字列で新規セッションを作成する。空白を含む入力は作成しない。cwd 候補がある状態で `-another` のように `-` から入力した場合は、cwd 候補にサフィックスとして足して `work.company-a.api-another` のように作成する。 +- 絞り込み結果が0件の状態で Enter すると、入力した文字列で新規セッションを作成する。空白を含む入力は作成しない。cwd 候補がある状態で `-another` のように `-` から入力した場合は、cwd 候補にサフィックスとして足して `work.company-a.api-another` のように作成する。cwd 候補が無い状態で `-scratch` のように `-` から入力した場合は、オプションと誤認されうるため作成しない。 - 文字を入力すれば case-insensitive substring AND で絞り込める - 絞り込みは case-insensitive の substring AND マッチ。`company api` のように空白で AND 検索できる。 - 候補が画面に収まらない場合はカーソル移動に合わせてスクロールする。 diff --git a/internal/tui/select.go b/internal/tui/select.go index aab83fb..0570fb2 100644 --- a/internal/tui/select.go +++ b/internal/tui/select.go @@ -197,10 +197,13 @@ func newSessionName(defaultItem, query string) (string, bool) { if name == "" { return "", false } + if containsSpace(name) { + return "", false + } if defaultItem != "" && strings.HasPrefix(name, "-") { return defaultItem + name, true } - if strings.HasPrefix(name, "-") || containsSpace(name) { + if strings.HasPrefix(name, "-") { return "", false } return name, true diff --git a/internal/tui/select_test.go b/internal/tui/select_test.go index 70436ad..bccd422 100644 --- a/internal/tui/select_test.go +++ b/internal/tui/select_test.go @@ -8,6 +8,15 @@ import ( 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 @@ -70,7 +79,7 @@ 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) @@ -81,9 +90,9 @@ func TestModelEnterSelectsHighlightedMatch(t *testing.T) { m := newModel([]string{"alpha", "beta"}) updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - m = updated.(model) + m = asModel(t, updated) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - got := updated.(model).selected + got := asModel(t, updated).selected want := "beta" if got != want { t.Fatalf("selected = %q, want %q", got, want) @@ -96,7 +105,7 @@ func TestModelEnterCreatesTypedNameWhenNoMatch(t *testing.T) { m.refilter() updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - got := updated.(model).selected + got := asModel(t, updated).selected want := "new-session" if got != want { t.Fatalf("selected = %q, want %q", got, want) @@ -110,13 +119,29 @@ func TestModelEnterCreatesDefaultSuffixWhenNoMatch(t *testing.T) { m.refilter() updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - got := updated.(model).selected + got := asModel(t, updated).selected want := "dev.shp-another" if got != want { t.Fatalf("selected = %q, want %q", got, want) } } +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" @@ -135,7 +160,7 @@ func TestModelEnterRejectsNewNameWithWhitespace(t *testing.T) { m.refilter() updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - got := updated.(model).selected + got := asModel(t, updated).selected if got != "" { t.Fatalf("selected = %q, want empty", got) } @@ -150,7 +175,7 @@ func TestModelEnterRejectsLeadingDashWithoutDefault(t *testing.T) { m.refilter() updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - got := updated.(model).selected + got := asModel(t, updated).selected if got != "" { t.Fatalf("selected = %q, want empty", got) } @@ -174,10 +199,10 @@ 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 { From 0cc8acf0797419026c3d1ecd211f7de278dd2adf Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 22:15:27 +0900 Subject: [PATCH 05/10] Normalize TUI-created session names to the safe charset Free-text picker input bypassed the [A-Za-z0-9._-] normalization that cwd-derived names go through, so inputs like "foo/bar" or "../etc" became session names verbatim, diverging from the documented invariant. - Extract the char-mapping into session.Sanitize and share it between path-derived names (FromPath) and free-text names (newSessionName) - Add tests for normalization (foo/bar -> foo_bar) and full-width-space (U+3000) rejection, a documented Japanese-input boundary - Note the normalization in README/DEV --- DEV.md | 2 +- README.md | 2 +- internal/session/name.go | 21 +++++++++++++++------ internal/tui/select.go | 6 ++++-- internal/tui/select_test.go | 28 ++++++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/DEV.md b/DEV.md index 9d64312..00d3cdc 100644 --- a/DEV.md +++ b/DEV.md @@ -128,7 +128,7 @@ attach には常に `--dir .` を渡す。既存セッションへの attach で - 上部に `QUERY> ...` を表示し、下にフィルタ済み候補 - フィルタ: case-insensitive substring AND (`Filter(items, query)` として独立、テスト対象) - キー操作: 文字入力で絞り込み、Backspace、↑ / Ctrl-P、↓ / Ctrl-N、Enter、Esc / Ctrl-C -- 絞り込み結果が0件の Enter は新規セッション名として query を返す。空白を含む query は作成しない。query が `-` で始まり cwd 既定項目がある場合は `defaultItem + query` を返し、cwd 既定項目がない場合は作成しない。 +- 絞り込み結果が0件の Enter は新規セッション名として query を返す。空白を含む query は作成しない。query が `-` で始まり cwd 既定項目がある場合は `defaultItem + query` を返し、cwd 既定項目がない場合は作成しない。返す名前は `session.Sanitize` で cwd 由来名と同じ文字集合 (`[A-Za-z0-9._-]`、それ以外は `_`) に正規化する。 - Backspace は rune 単位で削除する - 候補が端末高に収まらない場合はカーソル位置に合わせてスクロールする - `SelectWithDefault(items, defaultItem)` で「先頭に置く既定項目」を渡せる。既定項目は `(cwd)` ラベル付きで表示され、初期カーソル位置になる。既存リスト内に重複があれば dedupe。 diff --git a/README.md b/README.md index 3d9016c..e5c5299 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ QUERY> ``` - Enter でカーソル位置に attach (該当セッションが無ければ `shpool attach --dir .` で現在のディレクトリに新規作成) -- 絞り込み結果が0件の状態で Enter すると、入力した文字列で新規セッションを作成する。空白を含む入力は作成しない。cwd 候補がある状態で `-another` のように `-` から入力した場合は、cwd 候補にサフィックスとして足して `work.company-a.api-another` のように作成する。cwd 候補が無い状態で `-scratch` のように `-` から入力した場合は、オプションと誤認されうるため作成しない。 +- 絞り込み結果が0件の状態で Enter すると、入力した文字列で新規セッションを作成する。空白を含む入力は作成しない。cwd 候補がある状態で `-another` のように `-` から入力した場合は、cwd 候補にサフィックスとして足して `work.company-a.api-another` のように作成する。cwd 候補が無い状態で `-scratch` のように `-` から入力した場合は、オプションと誤認されうるため作成しない。セッション名に使えない文字 (`[A-Za-z0-9._-]` 以外) は cwd 由来名と同じく `_` に置き換える。 - 文字を入力すれば case-insensitive substring AND で絞り込める - 絞り込みは case-insensitive の substring AND マッチ。`company api` のように空白で AND 検索できる。 - 候補が画面に収まらない場合はカーソル移動に合わせてスクロールする。 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/tui/select.go b/internal/tui/select.go index 0570fb2..f6fbfcf 100644 --- a/internal/tui/select.go +++ b/internal/tui/select.go @@ -10,6 +10,8 @@ import ( 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). @@ -201,12 +203,12 @@ func newSessionName(defaultItem, query string) (string, bool) { return "", false } if defaultItem != "" && strings.HasPrefix(name, "-") { - return defaultItem + name, true + return session.Sanitize(defaultItem + name), true } if strings.HasPrefix(name, "-") { return "", false } - return name, true + return session.Sanitize(name), true } func containsSpace(s string) bool { diff --git a/internal/tui/select_test.go b/internal/tui/select_test.go index bccd422..0af2023 100644 --- a/internal/tui/select_test.go +++ b/internal/tui/select_test.go @@ -169,6 +169,34 @@ func TestModelEnterRejectsNewNameWithWhitespace(t *testing.T) { } } +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 TestModelEnterNormalizesNewNameToSafeCharset(t *testing.T) { + m := newModel([]string{"dev.shp"}) + m.query = "foo/bar" + m.refilter() + + updated, _ := 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) + } +} + func TestModelEnterRejectsLeadingDashWithoutDefault(t *testing.T) { m := newModel([]string{"dev.shp"}) m.query = "-scratch" From 1973479e0d075fcc1ae0a4af3fea6305d775231c Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 22:15:27 +0900 Subject: [PATCH 06/10] Ignore Claude Code runtime dir (.claude/) .claude/ holds machine-local runtime state such as scheduled_tasks.lock and must not be committed. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bdae7dd..50ce85d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.test *.out /tmp +/.claude/ From 014c0b23b7d11c0ed6b06a4ef14a48a755369f15 Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 22:26:47 +0900 Subject: [PATCH 07/10] Never create or attach dash-leading session names newSessionName built defaultItem+query for a '-'-prefixed query without re-checking the combined result, so a cwd-derived default that itself starts with '-' (e.g. a ~/-repo dir) yielded names like '-repo-another'. That defeats the function's own option-injection guard: the name is passed to 'shpool attach --dir . ' and misparsed as a flag. - Reject dash-only input ('-', '--') and combined names that still lead with '-' in newSessionName - Defense-in-depth: emit 'shpool attach ... -- ' so any positional name (including an existing '-'-named session selected from the list) is never treated as an option - Tests for both paths --- internal/shpool/attach.go | 4 +++- internal/shpool/attach_test.go | 10 ++++++++-- internal/tui/select.go | 13 ++++++++++++- internal/tui/select_test.go | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) 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 f6fbfcf..e092252 100644 --- a/internal/tui/select.go +++ b/internal/tui/select.go @@ -202,8 +202,19 @@ func newSessionName(defaultItem, query string) (string, bool) { if containsSpace(name) { return "", false } + if strings.Trim(name, "-") == "" { + // Dash-only input ("-", "--", ...) is not a meaningful name. + return "", false + } if defaultItem != "" && strings.HasPrefix(name, "-") { - return session.Sanitize(defaultItem + name), true + 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 diff --git a/internal/tui/select_test.go b/internal/tui/select_test.go index 0af2023..07508ac 100644 --- a/internal/tui/select_test.go +++ b/internal/tui/select_test.go @@ -184,6 +184,38 @@ func TestModelEnterRejectsNewNameWithFullWidthSpace(t *testing.T) { } } +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" From 7a2fea90981c8da906958aaceba1c2dddeb37015 Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 22:35:42 +0900 Subject: [PATCH 08/10] Label sanitized name as (existing) when it collides with a session After normalization, a query like "foo/bar" can sanitize to an existing session "foo_bar" that the raw-query Filter never matched, so the picker advertised "foo_bar (new)" and Enter silently reattached the existing session (ignoring --dir .). Check the candidate against the session list and label it (existing) vs (new) so the displayed state matches what Enter does. Also assert the Enter quit command in the success-path Update tests, for symmetry with the rejection tests. --- internal/tui/select.go | 19 ++++++++++++++++++- internal/tui/select_test.go | 37 +++++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/internal/tui/select.go b/internal/tui/select.go index e092252..b9fc53f 100644 --- a/internal/tui/select.go +++ b/internal/tui/select.go @@ -194,6 +194,16 @@ func (m model) currentSelection() string { return name } +// contains reports whether name is already one of the listed sessions. +func (m model) contains(name string) bool { + for _, it := range m.all { + if it == name { + return true + } + } + return false +} + func newSessionName(defaultItem, query string) (string, bool) { name := strings.TrimSpace(query) if name == "" { @@ -296,7 +306,14 @@ func (m model) View() string { b.WriteString(cursorStyle.Render("> ")) b.WriteString(selectedStyle.Render(name)) b.WriteString(" ") - b.WriteString(dimStyle.Render("(new)")) + // 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.contains(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 { diff --git a/internal/tui/select_test.go b/internal/tui/select_test.go index 07508ac..eca44a1 100644 --- a/internal/tui/select_test.go +++ b/internal/tui/select_test.go @@ -91,12 +91,15 @@ func TestModelEnterSelectsHighlightedMatch(t *testing.T) { updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) m = asModel(t, updated) - updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + 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) { @@ -104,12 +107,15 @@ func TestModelEnterCreatesTypedNameWhenNoMatch(t *testing.T) { m.query = "new-session" m.refilter() - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + 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) { @@ -118,12 +124,15 @@ func TestModelEnterCreatesDefaultSuffixWhenNoMatch(t *testing.T) { m.query = "-another" m.refilter() - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + 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) { @@ -221,12 +230,32 @@ func TestModelEnterNormalizesNewNameToSafeCharset(t *testing.T) { m.query = "foo/bar" m.refilter() - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + 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.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 TestModelEnterRejectsLeadingDashWithoutDefault(t *testing.T) { From c041d3127c2eb3e836e7d5f74293d2ea8d1cbab5 Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 22:44:39 +0900 Subject: [PATCH 09/10] Base (existing) label on real sessions, not the injected cwd default orderWithDefault injects the cwd-derived default into m.all even when it is not a running session, so the new (existing)/(new) label treated a synthetic default as existing. Track the real shpool-list sessions separately and decide the label against that set, so an injected default that Enter would create is correctly shown as (new). --- internal/tui/select.go | 14 ++++++++++---- internal/tui/select_test.go | 21 ++++++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/internal/tui/select.go b/internal/tui/select.go index b9fc53f..7b112bf 100644 --- a/internal/tui/select.go +++ b/internal/tui/select.go @@ -34,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 { @@ -108,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 { @@ -194,9 +199,10 @@ func (m model) currentSelection() string { return name } -// contains reports whether name is already one of the listed sessions. -func (m model) contains(name string) bool { - for _, it := range m.all { +// 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 } @@ -309,7 +315,7 @@ func (m model) View() string { // 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.contains(name) { + if m.isExistingSession(name) { b.WriteString(dimStyle.Render("(existing)")) } else { b.WriteString(dimStyle.Render("(new)")) diff --git a/internal/tui/select_test.go b/internal/tui/select_test.go index eca44a1..c5eb948 100644 --- a/internal/tui/select_test.go +++ b/internal/tui/select_test.go @@ -243,7 +243,8 @@ func TestModelEnterNormalizesNewNameToSafeCharset(t *testing.T) { func TestModelViewLabelsSanitizedCollisionAsExisting(t *testing.T) { m := newModel([]string{"foo_bar", "dev.shp"}) - m.query = "foo/bar" // sanitizes to the existing "foo_bar" + 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() @@ -258,6 +259,24 @@ func TestModelViewLabelsSanitizedCollisionAsExisting(t *testing.T) { } } +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" From fc9df4ac076d9391bf1e5322196ab955b6e7d48e Mon Sep 17 00:00:00 2001 From: uzulla Date: Wed, 3 Jun 2026 22:56:21 +0900 Subject: [PATCH 10/10] Clarify whitespace-trim docs and fix memory-file nits - README/DEV: state that leading/trailing whitespace is trimmed and only inputs with inner whitespace are rejected; document the (existing)/(new) label and the dash-leading guard. Split the long rule lines into bullets. - .serena/memories: add trailing newlines; drop a duplicated "shall". --- .serena/memories/conventions.md | 2 +- .serena/memories/core.md | 2 +- .serena/memories/memory_maintenance.md | 4 ++-- .serena/memories/suggested_commands.md | 2 +- .serena/memories/task_completion.md | 2 +- .serena/memories/tech_stack.md | 2 +- DEV.md | 7 ++++++- README.md | 7 ++++++- 8 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.serena/memories/conventions.md b/.serena/memories/conventions.md index 6160441..943bbde 100644 --- a/.serena/memories/conventions.md +++ b/.serena/memories/conventions.md @@ -3,4 +3,4 @@ - 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)`. \ No newline at end of file +- 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 index 2414c82..792f308 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -3,4 +3,4 @@ - 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. \ No newline at end of file +- 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 index 6f84514..3dcf364 100644 --- a/.serena/memories/memory_maintenance.md +++ b/.serena/memories/memory_maintenance.md @@ -6,7 +6,7 @@ - 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, shall contain references to even more specific memories, and so on. + 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. @@ -30,4 +30,4 @@ Do not add: quick-read facts; generic language/framework knowledge; one-off task ## 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. \ No newline at end of file +- 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 index 2d4aafd..37403e5 100644 --- a/.serena/memories/suggested_commands.md +++ b/.serena/memories/suggested_commands.md @@ -4,4 +4,4 @@ - `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. \ No newline at end of file +- `go run ./cmd/shp --help` checks CLI usage text. diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md index c3636d4..9693fd4 100644 --- a/.serena/memories/task_completion.md +++ b/.serena/memories/task_completion.md @@ -3,4 +3,4 @@ - 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. \ No newline at end of file +- 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 index e67eb84..a657be1 100644 --- a/.serena/memories/tech_stack.md +++ b/.serena/memories/tech_stack.md @@ -2,4 +2,4 @@ - 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. \ No newline at end of file +- Build/install/test tasks are defined in `mise.toml`; plain `go` commands also work. diff --git a/DEV.md b/DEV.md index 00d3cdc..1ddb897 100644 --- a/DEV.md +++ b/DEV.md @@ -128,7 +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 を返す。空白を含む query は作成しない。query が `-` で始まり cwd 既定項目がある場合は `defaultItem + query` を返し、cwd 既定項目がない場合は作成しない。返す名前は `session.Sanitize` で cwd 由来名と同じ文字集合 (`[A-Za-z0-9._-]`、それ以外は `_`) に正規化する。 +- 絞り込み結果が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 e5c5299..581a64d 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,12 @@ QUERY> ``` - Enter でカーソル位置に attach (該当セッションが無ければ `shpool attach --dir .` で現在のディレクトリに新規作成) -- 絞り込み結果が0件の状態で Enter すると、入力した文字列で新規セッションを作成する。空白を含む入力は作成しない。cwd 候補がある状態で `-another` のように `-` から入力した場合は、cwd 候補にサフィックスとして足して `work.company-a.api-another` のように作成する。cwd 候補が無い状態で `-scratch` のように `-` から入力した場合は、オプションと誤認されうるため作成しない。セッション名に使えない文字 (`[A-Za-z0-9._-]` 以外) は cwd 由来名と同じく `_` に置き換える。 +- 絞り込み結果が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 検索できる。 - 候補が画面に収まらない場合はカーソル移動に合わせてスクロールする。