Skip to content
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
*.test
*.out
/tmp
/.claude/
2 changes: 2 additions & 0 deletions .serena/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/cache
/project.local.yml
6 changes: 6 additions & 0 deletions .serena/memories/conventions.md
Original file line number Diff line number Diff line change
@@ -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 . <name>` 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)`.
6 changes: 6 additions & 0 deletions .serena/memories/core.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions .serena/memories/memory_maintenance.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .serena/memories/suggested_commands.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .serena/memories/task_completion.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .serena/memories/tech_stack.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions DEV.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 検索できる。
- 候補が画面に収まらない場合はカーソル移動に合わせてスクロールする。
Expand Down
3 changes: 2 additions & 1 deletion cmd/shp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <session-name> Attach to the given session name (no TUI).
shp -f Force-attach to a session named after the current
directory (no TUI).
Expand Down
21 changes: 15 additions & 6 deletions internal/session/name.go
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -58,10 +71,6 @@ func FromPath(path, home string) string {
b.WriteRune('_')
}
}
if needsSuffix {
b.WriteByte('-')
b.WriteString(shortHash(rel))
}
return b.String()
}

Expand Down
4 changes: 3 additions & 1 deletion internal/shpool/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <NAME>, never as a flag.
args = append(args, "--", name)
return args
}
10 changes: 8 additions & 2 deletions internal/shpool/attach_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
93 changes: 89 additions & 4 deletions internal/tui/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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])
Expand Down Expand Up @@ -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()
Expand All @@ -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()
}
Expand Down
Loading
Loading