diff --git a/cmd/session_attach.go b/cmd/session_attach.go index 06c71b8..38c9920 100644 --- a/cmd/session_attach.go +++ b/cmd/session_attach.go @@ -52,6 +52,14 @@ func runSessionAttach(cmd *cobra.Command, args []string) error { } } + // Record attach time and assign a numbered slot + if err := store.RecordAttach(name); err != nil { + fmt.Printf("Warning: Failed to record attach time: %v\n", err) + } + if _, err := store.AssignSlot(name); err != nil { + fmt.Printf("Warning: Failed to assign slot: %v\n", err) + } + // Check if the target tmux session exists if err := session.AttachTmuxSession(name); err != nil { // Session doesn't exist, try to launch it diff --git a/cmd/session_rm.go b/cmd/session_rm.go index d2ea570..839d074 100644 --- a/cmd/session_rm.go +++ b/cmd/session_rm.go @@ -75,9 +75,11 @@ func runSessionRm(cmd *cobra.Command, args []string) error { fmt.Printf("Warning: failed to remove git worktree: %v\n", err) } - // Remove session from metadata - if err := store.RemoveSession(name); err != nil { - return fmt.Errorf("failed to remove session metadata: %w", err) + // Remove session from metadata and reconcile slots in a single save + delete(store.Sessions, name) + store.ReconcileSlots() + if err := store.Save(); err != nil { + return fmt.Errorf("failed to save session metadata: %w", err) } // Sync Caddy routes after removal diff --git a/docs/plans/2026-02-14-faster-session-jumping-design.md b/docs/plans/2026-02-14-faster-session-jumping-design.md new file mode 100644 index 0000000..2f3a221 --- /dev/null +++ b/docs/plans/2026-02-14-faster-session-jumping-design.md @@ -0,0 +1,51 @@ +# Faster Session Jumping + +## Problem + +The TUI currently assigns hotkeys 1-9 based on positional index in the project-grouped session list. With many sessions across multiple projects, sessions beyond the 9th have no quick-jump shortcut. Users bounce between 3-5 active sessions and need faster navigation. + +## Design + +Two complementary features: MRU-based slot-pinned numbering and fuzzy search. + +### MRU Numbers with Slot Pinning + +**Data model changes** (`session/metadata.go`): +- Add `LastAttached time.Time` to `SessionData` — updated every time you attach +- Add `NumberedSlots map[int]string` to `SessionStore` — persisted map of slot (1-9) to session name + +**Slot assignment logic** (on attach): +1. If session already has a slot, do nothing (stable). +2. If there's a free slot (< 9 assigned), assign the lowest available number. +3. If all 9 slots are full, evict the session with the oldest `LastAttached` and give its slot to the new session. + +**Startup reconciliation**: On load, remove any slots pointing to sessions that no longer exist. This frees up slots naturally when sessions are deleted. + +**Display**: Number labels in the TUI come from `NumberedSlots` rather than positional index. Sessions not in a slot show no number. Numbers appear next to sessions wherever they fall in the project-grouped list. + +**Key behavior**: Pressing `1`-`9` jumps to the session assigned to that slot, not the nth item in the list. + +### Fuzzy Search + +**Trigger**: Press `/` in the session list to enter search mode. + +**Behavior**: +- A text input appears at the bottom of the session list. +- As you type, the session list filters to matching sessions (case-insensitive substring match on session name). +- Arrow keys navigate within filtered results. +- `Enter` jumps to the highlighted session and exits search mode. +- `Esc` cancels search and restores the full list. + +**Implementation**: +- New `stateSearch` added to the state enum. +- Reuse the existing `textinput.Model` on the model. +- Filter is applied in the view — `m.sessions` stays unmodified, only rendering changes. +- Cursor resets to 0 within filtered results. + +No persistence needed — search is purely ephemeral UI. + +### Key Binding Changes + +- `/` — enter search mode (only in `stateList`) +- `1`-`9` — jump to session by slot assignment rather than positional index +- Footer updated: `1-9: jump (MRU) • /: search` diff --git a/docs/plans/2026-02-14-faster-session-jumping.md b/docs/plans/2026-02-14-faster-session-jumping.md new file mode 100644 index 0000000..977b3ee --- /dev/null +++ b/docs/plans/2026-02-14-faster-session-jumping.md @@ -0,0 +1,812 @@ +# Faster Session Jumping Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add MRU-based slot-pinned number keys (1-9) and fuzzy search (/) to the TUI for faster session navigation. + +**Architecture:** Extend the persisted `SessionStore` with `LastAttached` timestamps and a `NumberedSlots` map. Slot assignment is automatic on attach, stable once assigned. Fuzzy search is ephemeral TUI state using a filtered index overlay. + +**Tech Stack:** Go, Bubble Tea (TUI), Cobra (CLI), JSON persistence + +--- + +### Task 1: Add `LastAttached` field to Session + +**Files:** +- Modify: `session/metadata.go:15-29` (Session struct) +- Test: `session/metadata_test.go` + +**Step 1: Write the failing test** + +Add to `session/metadata_test.go`: + +```go +func TestSessionLastAttached(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + _ = store.AddSession("test-sess", "main", "/path", map[string]int{"PORT": 3000}) + + // LastAttached should be zero initially + sess, _ := store.GetSession("test-sess") + if !sess.LastAttached.IsZero() { + t.Error("expected LastAttached to be zero initially") + } + + // Record attach + err = store.RecordAttach("test-sess") + if err != nil { + t.Fatalf("failed to record attach: %v", err) + } + + sess, _ = store.GetSession("test-sess") + if sess.LastAttached.IsZero() { + t.Error("expected LastAttached to be set after RecordAttach") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./session/ -run TestSessionLastAttached -v` +Expected: FAIL — `RecordAttach` not defined + +**Step 3: Write minimal implementation** + +In `session/metadata.go`, add `LastAttached` to the `Session` struct: + +```go +type Session struct { + Name string `json:"name"` + ProjectAlias string `json:"project_alias,omitempty"` + ProjectPath string `json:"project_path,omitempty"` + Branch string `json:"branch"` + Path string `json:"path"` + Ports map[string]int `json:"ports"` + Routes map[string]string `json:"routes,omitempty"` + EditorPID int `json:"editor_pid,omitempty"` + AttentionFlag bool `json:"attention_flag,omitempty"` + AttentionReason string `json:"attention_reason,omitempty"` + AttentionTime time.Time `json:"attention_time,omitempty"` + LastAttached time.Time `json:"last_attached,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +Add `RecordAttach` method to `SessionStore`: + +```go +// RecordAttach updates the LastAttached timestamp for a session +func (s *SessionStore) RecordAttach(name string) error { + return s.UpdateSession(name, func(sess *Session) { + sess.LastAttached = time.Now() + }) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./session/ -run TestSessionLastAttached -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add session/metadata.go session/metadata_test.go +git commit -m "feat: add LastAttached timestamp to Session" +``` + +--- + +### Task 2: Add `NumberedSlots` to SessionStore with slot logic + +**Files:** +- Modify: `session/metadata.go:31-33` (SessionStore struct) +- Test: `session/metadata_test.go` + +**Step 1: Write the failing tests** + +Add to `session/metadata_test.go`: + +```go +func TestNumberedSlots_AssignSlot(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + _ = store.AddSession("sess-a", "main", "/a", map[string]int{}) + _ = store.AddSession("sess-b", "main", "/b", map[string]int{}) + + // Assign slot for sess-a — should get slot 1 (lowest available) + slot, err := store.AssignSlot("sess-a") + if err != nil { + t.Fatalf("failed to assign slot: %v", err) + } + if slot != 1 { + t.Errorf("expected slot 1, got %d", slot) + } + + // Assign slot for sess-b — should get slot 2 + slot, err = store.AssignSlot("sess-b") + if err != nil { + t.Fatalf("failed to assign slot: %v", err) + } + if slot != 2 { + t.Errorf("expected slot 2, got %d", slot) + } + + // Assign again for sess-a — should keep slot 1 (stable) + slot, err = store.AssignSlot("sess-a") + if err != nil { + t.Fatalf("failed to assign slot: %v", err) + } + if slot != 1 { + t.Errorf("expected sess-a to keep slot 1, got %d", slot) + } +} + +func TestNumberedSlots_EvictLRU(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + + // Create 10 sessions, assign slots to first 9 + for i := 1; i <= 10; i++ { + name := fmt.Sprintf("sess-%d", i) + _ = store.AddSession(name, "main", fmt.Sprintf("/%d", i), map[string]int{}) + // Set LastAttached so sess-1 is oldest + _ = store.UpdateSession(name, func(s *Session) { + s.LastAttached = time.Now().Add(time.Duration(i) * time.Minute) + }) + } + + for i := 1; i <= 9; i++ { + _, _ = store.AssignSlot(fmt.Sprintf("sess-%d", i)) + } + + // All 9 slots full. Assign slot for sess-10 — should evict sess-1 (oldest LastAttached) + slot, err := store.AssignSlot("sess-10") + if err != nil { + t.Fatalf("failed to assign slot: %v", err) + } + + // sess-10 should have taken sess-1's slot (slot 1) + if slot != 1 { + t.Errorf("expected sess-10 to get slot 1 (evicting sess-1), got %d", slot) + } + + // sess-1 should no longer have a slot + if s := store.GetSlotForSession("sess-1"); s != 0 { + t.Errorf("expected sess-1 to have no slot, got %d", s) + } +} + +func TestNumberedSlots_Reconcile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + _ = store.AddSession("sess-a", "main", "/a", map[string]int{}) + _, _ = store.AssignSlot("sess-a") + + // Remove session, then reconcile — slot should be freed + _ = store.RemoveSession("sess-a") + store.ReconcileSlots() + + if s := store.GetSlotForSession("sess-a"); s != 0 { + t.Errorf("expected no slot for removed session, got %d", s) + } +} + +func TestNumberedSlots_GetSessionForSlot(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + _ = store.AddSession("sess-a", "main", "/a", map[string]int{}) + _, _ = store.AssignSlot("sess-a") + + name := store.GetSessionForSlot(1) + if name != "sess-a" { + t.Errorf("expected 'sess-a' for slot 1, got '%s'", name) + } + + name = store.GetSessionForSlot(5) + if name != "" { + t.Errorf("expected empty for unassigned slot 5, got '%s'", name) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./session/ -run "TestNumberedSlots" -v` +Expected: FAIL — methods not defined + +**Step 3: Write implementation** + +In `session/metadata.go`, update `SessionStore`: + +```go +type SessionStore struct { + Sessions map[string]*Session `json:"sessions"` + NumberedSlots map[int]string `json:"numbered_slots,omitempty"` +} +``` + +Update `LoadSessions` to initialize `NumberedSlots` if nil (after the existing `Sessions` nil check): + +```go +if store.NumberedSlots == nil { + store.NumberedSlots = make(map[int]string) +} +``` + +Also update `ClearRegistry`: + +```go +func ClearRegistry() error { + store := &SessionStore{ + Sessions: make(map[string]*Session), + NumberedSlots: make(map[int]string), + } + return store.Save() +} +``` + +Add slot methods: + +```go +// AssignSlot assigns a numbered slot (1-9) to a session. +// If the session already has a slot, returns the existing slot (stable). +// If a free slot exists, assigns the lowest available. +// If all 9 are full, evicts the session with the oldest LastAttached. +func (s *SessionStore) AssignSlot(name string) (int, error) { + if _, exists := s.Sessions[name]; !exists { + return 0, fmt.Errorf("session '%s' not found", name) + } + + // Check if session already has a slot + if slot := s.GetSlotForSession(name); slot != 0 { + return slot, nil + } + + // Find lowest available slot (1-9) + for i := 1; i <= 9; i++ { + if _, taken := s.NumberedSlots[i]; !taken { + s.NumberedSlots[i] = name + return i, s.Save() + } + } + + // All slots full — evict the session with the oldest LastAttached + oldestSlot := 0 + var oldestTime time.Time + for slot, sessName := range s.NumberedSlots { + sess, exists := s.Sessions[sessName] + if !exists { + // Stale slot, use it immediately + s.NumberedSlots[slot] = name + return slot, s.Save() + } + if oldestSlot == 0 || sess.LastAttached.Before(oldestTime) { + oldestSlot = slot + oldestTime = sess.LastAttached + } + } + + s.NumberedSlots[oldestSlot] = name + return oldestSlot, s.Save() +} + +// GetSlotForSession returns the slot number for a session, or 0 if unassigned. +func (s *SessionStore) GetSlotForSession(name string) int { + for slot, sessName := range s.NumberedSlots { + if sessName == name { + return slot + } + } + return 0 +} + +// GetSessionForSlot returns the session name assigned to a slot, or "" if empty. +func (s *SessionStore) GetSessionForSlot(slot int) string { + return s.NumberedSlots[slot] +} + +// ReconcileSlots removes slot assignments for sessions that no longer exist. +func (s *SessionStore) ReconcileSlots() { + for slot, name := range s.NumberedSlots { + if _, exists := s.Sessions[name]; !exists { + delete(s.NumberedSlots, slot) + } + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./session/ -run "TestNumberedSlots" -v` +Expected: PASS + +**Step 5: Run all session tests to check for regressions** + +Run: `go test ./session/ -v` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add session/metadata.go session/metadata_test.go +git commit -m "feat: add NumberedSlots with slot assignment and eviction logic" +``` + +--- + +### Task 3: Record attach + assign slot on session attach + +**Files:** +- Modify: `cmd/session_attach.go:24-73` (runSessionAttach) +- Modify: `tui/model.go:1743-1791` (attachSession method) + +**Step 1: Update CLI attach command** + +In `cmd/session_attach.go`, after the attention flag clearing block (line 53) and before the tmux attach (line 56), add: + +```go +// Record attach time and assign a numbered slot +if err := store.RecordAttach(name); err != nil { + fmt.Printf("Warning: Failed to record attach time: %v\n", err) +} +if _, err := store.AssignSlot(name); err != nil { + fmt.Printf("Warning: Failed to assign slot: %v\n", err) +} +``` + +**Step 2: Update TUI attach** + +In `tui/model.go`, inside the `attachSession` method, after the attention flag check/clear and before the `attachCmd` call (around line 1774), add the same logic: + +```go +// Record attach time and assign a numbered slot +if err := store.RecordAttach(name); err != nil { + m.debugLogger.Printf("Warning: Failed to record attach time: %v", err) +} +if _, err := store.AssignSlot(name); err != nil { + m.debugLogger.Printf("Warning: Failed to assign slot: %v", err) +} +``` + +**Step 3: Run full test suite** + +Run: `go test ./... -v` +Expected: All PASS (no test for this wiring — covered by integration) + +**Step 4: Commit** + +```bash +git add cmd/session_attach.go tui/model.go +git commit -m "feat: record LastAttached and assign slot on session attach" +``` + +--- + +### Task 4: Wire MRU slots into TUI display and key handling + +**Files:** +- Modify: `tui/model.go` + +**Step 1: Add `numberedSlots` to TUI model** + +Add a field to the `model` struct (around line 64): + +```go +numberedSlots map[int]string // slot number -> session name (loaded from store) +``` + +Initialize it in `InitialModel()`: + +```go +numberedSlots: make(map[int]string), +``` + +**Step 2: Load slots when sessions load** + +In the `sessionsLoadedMsg` handler (around line 747), after sessions are set, load and reconcile slots: + +```go +// Load numbered slots +slotStore, slotErr := session.LoadSessions() +if slotErr == nil { + slotStore.ReconcileSlots() + _ = slotStore.Save() + m.numberedSlots = slotStore.NumberedSlots +} else { + m.numberedSlots = make(map[int]string) +} +``` + +**Step 3: Add helper to find session index by name** + +```go +// sessionIndexByName returns the index of a session in m.sessions, or -1. +func (m *model) sessionIndexByName(name string) int { + for i, s := range m.sessions { + if s.name == name { + return i + } + } + return -1 +} +``` + +**Step 4: Change number key handler** + +Replace the existing number key handler in `stateList` (lines 537-542): + +```go +// Handle number keys 1-9 for quick navigation (MRU slot-based) +case msg.String() >= "1" && msg.String() <= "9": + slot := int(msg.String()[0] - '0') + if sessName, ok := m.numberedSlots[slot]; ok { + if idx := m.sessionIndexByName(sessName); idx >= 0 { + m.cursor = idx + } + } +``` + +**Step 5: Change display to show slot numbers instead of positional numbers** + +In `listView()`, replace the positional number prefix logic. This appears in two places (non-preview at ~line 1057, and preview at ~line 1141). In both places, replace: + +```go +// Add number shortcut for first 9 items +numberPrefix := "" +if i < 9 { + numberPrefix = fmt.Sprintf("%d. ", i+1) +} else { + numberPrefix = " " // Maintain alignment +} +``` + +With: + +```go +// Add MRU slot number if this session has one +numberPrefix := " " // Default: no number +for slot, name := range m.numberedSlots { + if name == sess.name { + numberPrefix = fmt.Sprintf("%d. ", slot) + break + } +} +``` + +**Step 6: Run the app manually to verify** + +Run: `go build -o /tmp/devx-test . && /tmp/devx-test` +Verify: Sessions show slot numbers, pressing number keys jumps to the correct session. + +**Step 7: Commit** + +```bash +git add tui/model.go +git commit -m "feat: wire MRU slot numbers into TUI display and key handling" +``` + +--- + +### Task 5: Add fuzzy search mode + +**Files:** +- Modify: `tui/model.go` + +**Step 1: Add search state and fields** + +Add `stateSearch` to the state enum (after `stateProjectAdd`): + +```go +stateSearch +``` + +Add fields to `model` struct: + +```go +searchInput textinput.Model +searchFilter string +filteredIndices []int // indices into m.sessions that match the filter +searchCursor int // cursor within filtered results +``` + +Initialize `searchInput` in `InitialModel()`: + +```go +si := textinput.New() +si.Placeholder = "search sessions..." +si.CharLimit = 50 +``` + +And in the model initialization: + +```go +searchInput: si, +``` + +**Step 2: Add `/` key binding** + +Add to `keyMap` struct: + +```go +Search key.Binding +``` + +Add to `keys` var: + +```go +Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search"), +), +``` + +**Step 3: Add search key handler in `stateList`** + +In the `stateList` switch block, add a case before the number keys handler: + +```go +case key.Matches(msg, m.keys.Search): + m.state = stateSearch + m.searchInput.Reset() + m.searchInput.Focus() + m.searchFilter = "" + m.filteredIndices = nil + m.searchCursor = 0 + return m, textinput.Blink +``` + +**Step 4: Add `stateSearch` key handling in Update** + +Add a new case in the `m.state` switch: + +```go +case stateSearch: + switch { + case key.Matches(msg, m.keys.Back): + m.state = stateList + m.searchInput.Blur() + m.searchFilter = "" + m.filteredIndices = nil + + case msg.Type == tea.KeyEnter: + // Jump to selected filtered result + if len(m.filteredIndices) > 0 && m.searchCursor < len(m.filteredIndices) { + m.cursor = m.filteredIndices[m.searchCursor] + } + m.state = stateList + m.searchInput.Blur() + m.searchFilter = "" + m.filteredIndices = nil + + case key.Matches(msg, m.keys.Up): + if m.searchCursor > 0 { + m.searchCursor-- + } + + case key.Matches(msg, m.keys.Down): + if m.searchCursor < len(m.filteredIndices)-1 { + m.searchCursor++ + } + + default: + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + // Update filter + m.searchFilter = strings.ToLower(strings.TrimSpace(m.searchInput.Value())) + m.filteredIndices = nil + m.searchCursor = 0 + for i, sess := range m.sessions { + if m.searchFilter == "" || strings.Contains(strings.ToLower(sess.name), m.searchFilter) { + m.filteredIndices = append(m.filteredIndices, i) + } + } + return m, cmd + } +``` + +**Step 5: Add search view rendering** + +In the `View()` method's state switch, add: + +```go +case stateSearch: + content = m.searchView() +``` + +Add the `searchView` method: + +```go +func (m *model) searchView() string { + var b strings.Builder + b.WriteString(headerStyle.Render("Sessions") + "\n\n") + + if len(m.filteredIndices) == 0 && m.searchFilter != "" { + b.WriteString(dimStyle.Render(" No matching sessions") + "\n") + } else { + indices := m.filteredIndices + if indices == nil { + // Show all sessions before any typing + indices = make([]int, len(m.sessions)) + for i := range m.sessions { + indices[i] = i + } + } + + var currentProject string + for filterIdx, sessIdx := range indices { + sess := m.sessions[sessIdx] + + // Project header + if sess.projectAlias != currentProject { + if currentProject != "" { + b.WriteString("\n") + } + currentProject = sess.projectAlias + projectHeader := "No Project" + if sess.projectAlias != "" { + if sess.projectName != "" { + projectHeader = fmt.Sprintf("%s (%s)", sess.projectName, sess.projectAlias) + } else { + projectHeader = sess.projectAlias + } + } + b.WriteString(headerStyle.Render(projectHeader) + "\n") + } + + cursor := " " + if filterIdx == m.searchCursor { + cursor = "> " + } + + // MRU slot number + numberPrefix := " " + for slot, name := range m.numberedSlots { + if name == sess.name { + numberPrefix = fmt.Sprintf("%d. ", slot) + break + } + } + + line := fmt.Sprintf("%s%s %s", cursor, numberPrefix, sess.name) + if filterIdx == m.searchCursor { + line = selectedStyle.Render(line) + } + b.WriteString(line + "\n") + } + } + + b.WriteString("\n") + searchBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("170")). + Padding(0, 1). + Width(40). + MarginLeft(2) + b.WriteString(searchBox.Render("/ " + m.searchInput.View())) + + return b.String() +} +``` + +**Step 6: Add footer for search state** + +In the footer switch in `View()`, add: + +```go +case stateSearch: + footer = footerStyle.Width(m.width).Render("↑/↓: navigate • enter: jump to session • esc: cancel search") +``` + +**Step 7: Run the app manually to verify** + +Run: `go build -o /tmp/devx-test . && /tmp/devx-test` +Verify: Press `/`, type a few chars, see filtered results, Enter to jump, Esc to cancel. + +**Step 8: Commit** + +```bash +git add tui/model.go +git commit -m "feat: add fuzzy search mode with / key" +``` + +--- + +### Task 6: Update footer and help text + +**Files:** +- Modify: `tui/model.go` + +**Step 1: Update the stateList footer** + +Change the footer text (around line 962) from: + +``` +"↑/↓: navigate • 1-9: jump • enter: attach • ..." +``` + +To: + +``` +"↑/↓: navigate • 1-9: jump (MRU) • /: search • enter: attach • c: create • d: delete • o: open routes • e: edit • h: hostnames • P: projects • p: preview • ?: help • q: quit" +``` + +**Step 2: Update FullHelp** + +Add `Search` to the `FullHelp()` key bindings return so it shows in extended help. + +**Step 3: Commit** + +```bash +git add tui/model.go +git commit -m "feat: update footer and help text with MRU and search hints" +``` + +--- + +### Task 7: Final verification + +**Step 1: Run full pre-commit checklist** + +```bash +gofmt -w . +go vet ./... +golangci-lint run --timeout=5m +go test -v -race ./... +go mod tidy +``` + +**Step 2: Fix any issues found** + +**Step 3: Final commit if any fixes needed** + +```bash +git add -A +git commit -m "fix: address lint and test issues" +``` diff --git a/docs/plans/2026-02-15-inline-search-and-slot-bootstrap-design.md b/docs/plans/2026-02-15-inline-search-and-slot-bootstrap-design.md new file mode 100644 index 0000000..9ecfbaa --- /dev/null +++ b/docs/plans/2026-02-15-inline-search-and-slot-bootstrap-design.md @@ -0,0 +1,50 @@ +# Inline Search & Slot Bootstrap Design + +Date: 2026-02-15 + +## Problem + +Two UX issues with the TUI session manager: + +1. **MRU slot numbers never appear** — Slots are only assigned on attach, so if you open the TUI without having attached since the feature was added, no numbers show. There's no bootstrapping. + +2. **Search view is jarring** — Pressing `/` switches to a completely separate `stateSearch` / `searchView()` that drops the preview pane, loses visual context, and feels disconnected from the main list. + +## Design + +### Feature 1: Bootstrap MRU Slots on TUI Startup + +In the `sessionsLoadedMsg` handler (`tui/model.go` ~line 837), after loading and reconciling slots, iterate through all sessions and call `AssignSlot` for any that don't already have one. + +- `AssignSlot` already handles eviction (oldest `LastAttached` gets evicted when >9 sessions) +- First load: all sessions get slots 1-9 +- Subsequent refreshes: no-op since sessions already have slots +- New sessions created during TUI: get a slot on next refresh (~2s) + +No changes needed to `session/metadata.go`. + +### Feature 2: Inline Search in Main List View + +Replace the separate search state/view with inline filtering that stays in the main list view. + +**Model changes:** +- Add `filterActive bool` field to track whether filter bar is shown +- Reuse existing `searchInput`, `searchFilter`, `filteredIndices`, `searchCursor` +- Remove `stateSearch` state constant + +**Key handling (in `stateList`):** +- `/` → set `filterActive = true`, focus searchInput, Blink +- When `filterActive`: text input → searchInput, Esc clears filter, Enter closes filter and sets cursor to selected session +- Up/Down navigate only `filteredIndices` when filter active +- Number keys still work during filtering + +**Rendering (`listView`):** +- When `filterActive && searchFilter != ""`: skip non-matching sessions, hide empty project groups +- Cursor uses `searchCursor` mapped through `filteredIndices` +- Search input box rendered at bottom of list (both preview and non-preview layouts) +- Footer shows search-mode keybindings when filter active + +**Deletions:** +- `stateSearch` constant +- `searchView()` function +- `case stateSearch:` in Update and View diff --git a/docs/plans/2026-02-15-inline-search-and-slot-bootstrap-plan.md b/docs/plans/2026-02-15-inline-search-and-slot-bootstrap-plan.md new file mode 100644 index 0000000..7df4cc2 --- /dev/null +++ b/docs/plans/2026-02-15-inline-search-and-slot-bootstrap-plan.md @@ -0,0 +1,400 @@ +# Inline Search & Slot Bootstrap Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix two TUI UX issues: bootstrap MRU slot numbers on startup so they always show, and replace the jarring separate search view with inline filtering in the main list. + +**Architecture:** Both changes are in `tui/model.go`. Feature 1 adds a loop in the `sessionsLoadedMsg` handler. Feature 2 replaces `stateSearch` with a `filterActive` bool on `stateList`, merges search key handling into the list handler, and adds filter-awareness to the existing `listView()` render function. + +**Tech Stack:** Go, Bubble Tea (charmbracelet/bubbletea), lipgloss + +--- + +### Task 1: Bootstrap MRU Slots on Session Load + +**Files:** +- Modify: `tui/model.go:837-845` (sessionsLoadedMsg handler) + +**Step 1: Add slot bootstrapping after reconciliation** + +In the `sessionsLoadedMsg` handler, after reconciling slots and before setting `m.numberedSlots`, loop through all sessions in the store and assign slots to any that don't have one: + +```go +// Load numbered slots +slotStore, slotErr := session.LoadSessions() +if slotErr == nil { + slotStore.ReconcileSlots() + // Bootstrap: assign slots to any session that doesn't have one yet + for name := range slotStore.Sessions { + if slotStore.GetSlotForSession(name) == 0 { + slotStore.AssignSlot(name) + } + } + _ = slotStore.Save() + m.numberedSlots = slotStore.NumberedSlots +} else { + m.numberedSlots = make(map[int]string) +} +``` + +Note: `AssignSlot` already calls `Save()` internally, but we keep the outer `Save()` for the reconcile. The extra saves are harmless (idempotent file write). + +**Step 2: Build and verify** + +Run: `cd /Users/jfox/projects/devx/.worktrees/jf-faster-jumping && go build ./...` +Expected: Clean build, no errors. + +**Step 3: Run tests** + +Run: `go test -race ./...` +Expected: All tests pass. + +**Step 4: Commit** + +```bash +git add tui/model.go +git commit -m "feat: bootstrap MRU slots for all sessions on TUI load" +``` + +--- + +### Task 2: Add `filterActive` Field and Remove `stateSearch` + +**Files:** +- Modify: `tui/model.go:52-63` (state constants) +- Modify: `tui/model.go:65-119` (model struct) + +**Step 1: Remove `stateSearch` from the state enum** + +In the `const` block at line 54-63, delete the `stateSearch` line: + +```go +const ( + stateList state = iota + stateCreating + stateProjectSelect + stateConfirm + stateHostnames + stateProjectManagement + stateProjectAdd +) +``` + +**Step 2: Add `filterActive` field to model struct** + +In the model struct, replace the comment above search fields (line 114) and add `filterActive`: + +```go +// Search/filter fields +filterActive bool +searchInput textinput.Model +searchFilter string +filteredIndices []int // indices into m.sessions that match the filter +searchCursor int // cursor within filtered results +``` + +**Step 3: Build to check for compile errors** + +Run: `go build ./...` +Expected: Compile errors referencing `stateSearch` — this is expected, we'll fix them in the next tasks. + +**Step 4: Commit (WIP)** + +```bash +git add tui/model.go +git commit -m "refactor: replace stateSearch with filterActive field" +``` + +--- + +### Task 3: Merge Search Key Handling Into stateList + +**Files:** +- Modify: `tui/model.go:482-572` (stateList key handler) +- Modify: `tui/model.go:775-816` (stateSearch key handler — delete) + +**Step 1: Update the `/` key handler to set filterActive instead of switching state** + +Replace the `case key.Matches(msg, m.keys.Search):` block at line 488-495: + +```go +case key.Matches(msg, m.keys.Search): + m.filterActive = true + m.searchInput.Reset() + m.searchInput.Focus() + m.searchFilter = "" + m.filteredIndices = nil + m.searchCursor = 0 + return m, textinput.Blink +``` + +**Step 2: Add filter-active key handling at the TOP of the stateList case** + +Right after `case stateList:` (line 483) and before the existing `switch`, add a guard that intercepts keys when filter is active: + +```go +case stateList: + // When filter is active, intercept keys for the search input + if m.filterActive { + switch { + case key.Matches(msg, m.keys.Back): // Esc + m.filterActive = false + m.searchInput.Blur() + m.searchFilter = "" + m.filteredIndices = nil + m.searchCursor = 0 + + case msg.Type == tea.KeyEnter: + // Jump cursor to selected filtered result, close filter + if len(m.filteredIndices) > 0 && m.searchCursor < len(m.filteredIndices) { + m.cursor = m.filteredIndices[m.searchCursor] + } + m.filterActive = false + m.searchInput.Blur() + m.searchFilter = "" + m.filteredIndices = nil + + case key.Matches(msg, m.keys.Up): + if m.searchCursor > 0 { + m.searchCursor-- + } + + case key.Matches(msg, m.keys.Down): + if len(m.filteredIndices) > 0 && m.searchCursor < len(m.filteredIndices)-1 { + m.searchCursor++ + } + + default: + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + m.searchFilter = strings.ToLower(strings.TrimSpace(m.searchInput.Value())) + m.filteredIndices = nil + m.searchCursor = 0 + for i, sess := range m.sessions { + if m.searchFilter == "" || strings.Contains(strings.ToLower(sess.name), m.searchFilter) { + m.filteredIndices = append(m.filteredIndices, i) + } + } + return m, cmd + } + return m, nil + } + + switch { + // ... existing stateList key handlers unchanged ... +``` + +**Step 3: Delete the entire `case stateSearch:` block** + +Remove lines 775-816 (the `case stateSearch:` handler). + +**Step 4: Build and verify** + +Run: `go build ./...` +Expected: May still have compile errors from View function — that's ok, fixed in next task. + +**Step 5: Commit** + +```bash +git add tui/model.go +git commit -m "refactor: merge search key handling into stateList with filterActive" +``` + +--- + +### Task 4: Update View to Use Inline Filter + +**Files:** +- Modify: `tui/model.go:1020-1062` (View function — state switch and footer) +- Modify: `tui/model.go:1096-1174` (listView non-preview rendering) +- Modify: `tui/model.go:1200-1248` (listView preview rendering) +- Delete: `tui/model.go:1809-1884` (searchView function) + +**Step 1: Remove `stateSearch` from View's state switch** + +In the View function at line 1035-1036, delete: +```go +case stateSearch: + content = m.searchView() +``` + +**Step 2: Update footer to handle filterActive within stateList** + +Replace the `case stateList:` footer (line 1045-1046) with: + +```go +case stateList: + if m.filterActive { + footer = footerStyle.Width(m.width).Render("↑/↓: navigate • enter: jump to session • esc: cancel search") + } else { + footer = footerStyle.Width(m.width).Render("↑/↓: navigate • 1-9: jump (MRU) • /: search • enter: attach • c: create • d: delete • o: open routes • e: edit • h: hostnames • P: projects • p: preview • ?: help • q: quit") + } +``` + +Also delete the `case stateSearch:` footer line (line 1059-1060). + +**Step 3: Update non-preview listView to be filter-aware** + +In the non-preview loop (lines 1117-1172), replace the session iteration with filter-aware logic. The key idea: when `filterActive` and a filter is typed, use `filteredIndices` to decide which sessions to show, and use `searchCursor` for the cursor highlight. + +Replace the loop body (lines 1115-1172) with: + +```go +// Determine which sessions to display +type displayEntry struct { + sessIdx int // index into m.sessions + filterIdx int // position in filtered view (-1 if not filtered) +} + +var entries []displayEntry +if m.filterActive && m.searchFilter != "" && m.filteredIndices != nil { + for fi, si := range m.filteredIndices { + entries = append(entries, displayEntry{sessIdx: si, filterIdx: fi}) + } +} else { + for i := range m.sessions { + entries = append(entries, displayEntry{sessIdx: i, filterIdx: -1}) + } +} + +// Group sessions by project for display +var currentProject string +for _, entry := range entries { + sess := m.sessions[entry.sessIdx] + + // Add project header if this is a new project + if sess.projectAlias != currentProject { + if currentProject != "" { + b.WriteString("\n") + } + currentProject = sess.projectAlias + + projectHeader := "No Project" + if sess.projectAlias != "" { + if sess.projectName != "" { + projectHeader = fmt.Sprintf("%s (%s)", sess.projectName, sess.projectAlias) + } else { + projectHeader = sess.projectAlias + } + } + + b.WriteString(headerStyle.Render(projectHeader) + "\n") + } + + // Cursor logic: use searchCursor when filtering, else main cursor + cursor := " " + if entry.filterIdx >= 0 { + if entry.filterIdx == m.searchCursor { + cursor = "> " + } + } else if m.cursor == entry.sessIdx { + cursor = "> " + } + + // Add MRU slot number if this session has one + numberPrefix := " " + for slot, name := range m.numberedSlots { + if name == sess.name { + numberPrefix = fmt.Sprintf("%d. ", slot) + break + } + } + + // Add attention indicator + indicator := " " + if sess.attentionFlag { + indicator = "🔔" + } + + isCursorLine := (entry.filterIdx >= 0 && entry.filterIdx == m.searchCursor) || (entry.filterIdx < 0 && m.cursor == entry.sessIdx) + line := fmt.Sprintf("%s%s%s %s", cursor, numberPrefix, indicator, sess.name) + if isCursorLine { + line = selectedStyle.Render(line) + } + b.WriteString(line + "\n") + + // Show details for selected session (inline, only in non-filter mode) + if !m.filterActive && m.cursor == entry.sessIdx { + details := m.getSessionDetails(sess) + lines := strings.Split(strings.TrimSuffix(details, "\n"), "\n") + for _, line := range lines { + b.WriteString(dimStyle.Render(line) + "\n") + } + } +} + +// Show search input at bottom when filter is active +if m.filterActive { + b.WriteString("\n") + searchBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("170")). + Padding(0, 1). + Width(40). + MarginLeft(2) + b.WriteString(searchBox.Render("/ " + m.searchInput.View())) +} +``` + +**Step 4: Update preview listView with the same filter logic** + +Apply the same `displayEntry` pattern to the preview layout loop (lines 1201-1248). Same logic but without the inline session details (preview layout doesn't show them). Add the search box at the bottom of the session list string, before the pane join. + +**Step 5: Delete the `searchView()` function** + +Remove the entire `searchView()` function (lines 1809-1884). + +**Step 6: Build and verify** + +Run: `go build ./...` +Expected: Clean build. + +**Step 7: Run all tests** + +Run: `go test -race ./...` +Expected: All pass. + +**Step 8: Commit** + +```bash +git add tui/model.go +git commit -m "feat: inline search filtering in main list view, remove separate search view" +``` + +--- + +### Task 5: Final Verification + +**Files:** None (testing only) + +**Step 1: Run full pre-commit checklist** + +```bash +gofmt -w . +go vet ./... +golangci-lint run --timeout=5m +go test -v -race ./... +go mod tidy +``` + +Expected: All pass, no changes from gofmt. + +**Step 2: Build and smoke test** + +```bash +make dev +``` + +Launch the TUI and verify: +1. MRU slot numbers (1-9) appear next to sessions immediately on load +2. Pressing `/` shows a search box at the bottom of the main view (not a separate screen) +3. Typing filters the session list, keeping project group headers for matching sessions +4. Up/Down navigates filtered results +5. Enter closes the filter and leaves cursor on the selected session +6. Esc cancels the filter and restores the full list +7. Number keys (1-9) still work for slot jumping +8. Preview pane stays visible during search + +**Step 3: Amend or create final commit if needed** + +If gofmt or vet caught anything, fix and commit. diff --git a/session/metadata.go b/session/metadata.go index 5f56a20..cf1829b 100644 --- a/session/metadata.go +++ b/session/metadata.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "time" @@ -24,12 +25,14 @@ type Session struct { AttentionFlag bool `json:"attention_flag,omitempty"` AttentionReason string `json:"attention_reason,omitempty"` // "claude_done", "claude_stuck", "manual", etc. AttentionTime time.Time `json:"attention_time,omitempty"` + LastAttached time.Time `json:"last_attached,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type SessionStore struct { - Sessions map[string]*Session `json:"sessions"` + Sessions map[string]*Session `json:"sessions"` + NumberedSlots map[int]string `json:"numbered_slots,omitempty"` } // LoadSessions loads the sessions from the metadata file @@ -44,7 +47,10 @@ func LoadSessions() (*SessionStore, error) { // If file doesn't exist, return empty store if _, err := os.Stat(sessionsPath); os.IsNotExist(err) { - return &SessionStore{Sessions: make(map[string]*Session)}, nil + return &SessionStore{ + Sessions: make(map[string]*Session), + NumberedSlots: make(map[int]string), + }, nil } data, err := os.ReadFile(sessionsPath) @@ -60,6 +66,9 @@ func LoadSessions() (*SessionStore, error) { if store.Sessions == nil { store.Sessions = make(map[string]*Session) } + if store.NumberedSlots == nil { + store.NumberedSlots = make(map[int]string) + } return &store, nil } @@ -140,6 +149,13 @@ func (s *SessionStore) UpdateSession(name string, updateFn func(*Session)) error return s.Save() } +// RecordAttach updates the LastAttached timestamp for a session +func (s *SessionStore) RecordAttach(name string) error { + return s.UpdateSession(name, func(sess *Session) { + sess.LastAttached = time.Now() + }) +} + // RemoveSession removes a session from the store func (s *SessionStore) RemoveSession(name string) error { if _, exists := s.Sessions[name]; !exists { @@ -157,7 +173,10 @@ func LoadRegistry() (*SessionStore, error) { // ClearRegistry removes all sessions and clears the sessions file func ClearRegistry() error { - store := &SessionStore{Sessions: make(map[string]*Session)} + store := &SessionStore{ + Sessions: make(map[string]*Session), + NumberedSlots: make(map[int]string), + } return store.Save() } @@ -304,3 +323,77 @@ func GetCurrentSessionName() string { return "" } + +// AssignSlot assigns a numbered slot (1-9) to a session. +// If the session already has a slot, returns the existing slot (stable). +// If a free slot exists, assigns the lowest available. +// If all 9 are full, evicts the session with the oldest LastAttached. +func (s *SessionStore) AssignSlot(name string) (int, error) { + if _, exists := s.Sessions[name]; !exists { + return 0, fmt.Errorf("session '%s' not found", name) + } + + // Check if session already has a slot + if slot := s.GetSlotForSession(name); slot != 0 { + return slot, nil + } + + // Find lowest available slot (1-9) + for i := 1; i <= 9; i++ { + if _, taken := s.NumberedSlots[i]; !taken { + s.NumberedSlots[i] = name + return i, s.Save() + } + } + + // All slots full — evict the session with the oldest LastAttached. + // Iterate slots in ascending order for deterministic tie-breaking. + slots := make([]int, 0, len(s.NumberedSlots)) + for slot := range s.NumberedSlots { + slots = append(slots, slot) + } + sort.Ints(slots) + + oldestSlot := 0 + var oldestTime time.Time + for _, slot := range slots { + sessName := s.NumberedSlots[slot] + sess, exists := s.Sessions[sessName] + if !exists { + // Stale slot, use it immediately + s.NumberedSlots[slot] = name + return slot, s.Save() + } + if oldestSlot == 0 || sess.LastAttached.Before(oldestTime) { + oldestSlot = slot + oldestTime = sess.LastAttached + } + } + + s.NumberedSlots[oldestSlot] = name + return oldestSlot, s.Save() +} + +// GetSlotForSession returns the slot number for a session, or 0 if unassigned. +func (s *SessionStore) GetSlotForSession(name string) int { + for slot, sessName := range s.NumberedSlots { + if sessName == name { + return slot + } + } + return 0 +} + +// GetSessionForSlot returns the session name assigned to a slot, or "" if empty. +func (s *SessionStore) GetSessionForSlot(slot int) string { + return s.NumberedSlots[slot] +} + +// ReconcileSlots removes slot assignments for sessions that no longer exist. +func (s *SessionStore) ReconcileSlots() { + for slot, name := range s.NumberedSlots { + if _, exists := s.Sessions[name]; !exists { + delete(s.NumberedSlots, slot) + } + } +} diff --git a/session/metadata_test.go b/session/metadata_test.go index ef9b75f..8dd0b3a 100644 --- a/session/metadata_test.go +++ b/session/metadata_test.go @@ -1,8 +1,10 @@ package session import ( + "fmt" "os" "testing" + "time" ) func TestSessionStore(t *testing.T) { @@ -120,3 +122,284 @@ func TestSessionStoreUpdate(t *testing.T) { t.Error("expected UpdatedAt to be different from CreatedAt") } } + +func TestSessionLastAttached(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + _ = store.AddSession("test-sess", "main", "/path", map[string]int{"PORT": 3000}) + + // LastAttached should be zero initially + sess, _ := store.GetSession("test-sess") + if !sess.LastAttached.IsZero() { + t.Error("expected LastAttached to be zero initially") + } + + // Record attach + err = store.RecordAttach("test-sess") + if err != nil { + t.Fatalf("failed to record attach: %v", err) + } + + sess, _ = store.GetSession("test-sess") + if sess.LastAttached.IsZero() { + t.Error("expected LastAttached to be set after RecordAttach") + } +} + +func TestNumberedSlots_AssignSlot(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + _ = store.AddSession("sess-a", "main", "/a", map[string]int{}) + _ = store.AddSession("sess-b", "main", "/b", map[string]int{}) + + // Assign slot for sess-a — should get slot 1 (lowest available) + slot, err := store.AssignSlot("sess-a") + if err != nil { + t.Fatalf("failed to assign slot: %v", err) + } + if slot != 1 { + t.Errorf("expected slot 1, got %d", slot) + } + + // Assign slot for sess-b — should get slot 2 + slot, err = store.AssignSlot("sess-b") + if err != nil { + t.Fatalf("failed to assign slot: %v", err) + } + if slot != 2 { + t.Errorf("expected slot 2, got %d", slot) + } + + // Assign again for sess-a — should keep slot 1 (stable) + slot, err = store.AssignSlot("sess-a") + if err != nil { + t.Fatalf("failed to assign slot: %v", err) + } + if slot != 1 { + t.Errorf("expected sess-a to keep slot 1, got %d", slot) + } +} + +func TestNumberedSlots_EvictLRU(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + + // Create 10 sessions, assign slots to first 9 + for i := 1; i <= 10; i++ { + name := fmt.Sprintf("sess-%d", i) + _ = store.AddSession(name, "main", fmt.Sprintf("/%d", i), map[string]int{}) + // Set LastAttached so sess-1 is oldest + _ = store.UpdateSession(name, func(s *Session) { + s.LastAttached = time.Now().Add(time.Duration(i) * time.Minute) + }) + } + + for i := 1; i <= 9; i++ { + _, _ = store.AssignSlot(fmt.Sprintf("sess-%d", i)) + } + + // All 9 slots full. Assign slot for sess-10 — should evict sess-1 (oldest LastAttached) + slot, err := store.AssignSlot("sess-10") + if err != nil { + t.Fatalf("failed to assign slot: %v", err) + } + + // sess-10 should have taken sess-1's slot (slot 1) + if slot != 1 { + t.Errorf("expected sess-10 to get slot 1 (evicting sess-1), got %d", slot) + } + + // sess-1 should no longer have a slot + if s := store.GetSlotForSession("sess-1"); s != 0 { + t.Errorf("expected sess-1 to have no slot, got %d", s) + } +} + +func TestNumberedSlots_Reconcile(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + _ = store.AddSession("sess-a", "main", "/a", map[string]int{}) + _, _ = store.AssignSlot("sess-a") + + // Remove session, then reconcile — slot should be freed + _ = store.RemoveSession("sess-a") + store.ReconcileSlots() + + if s := store.GetSlotForSession("sess-a"); s != 0 { + t.Errorf("expected no slot for removed session, got %d", s) + } +} + +func TestRecordAttach_NonexistentSession(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + + err = store.RecordAttach("nonexistent") + if err == nil { + t.Error("expected error when recording attach for nonexistent session") + } +} + +func TestAssignSlot_NonexistentSession(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + + _, err = store.AssignSlot("nonexistent") + if err == nil { + t.Error("expected error when assigning slot for nonexistent session") + } +} + +func TestNumberedSlots_EvictAllZeroLastAttached(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + + // Create 10 sessions, all with zero LastAttached + for i := 1; i <= 10; i++ { + name := fmt.Sprintf("sess-%d", i) + _ = store.AddSession(name, "main", fmt.Sprintf("/%d", i), map[string]int{}) + } + for i := 1; i <= 9; i++ { + _, _ = store.AssignSlot(fmt.Sprintf("sess-%d", i)) + } + + // All zero LastAttached: eviction should still succeed deterministically + // (lowest slot number with zero time gets evicted) + slot, err := store.AssignSlot("sess-10") + if err != nil { + t.Fatalf("failed to assign slot with all-zero LastAttached: %v", err) + } + if slot < 1 || slot > 9 { + t.Errorf("expected valid slot 1-9, got %d", slot) + } + + // Verify sess-10 now has the slot + if got := store.GetSlotForSession("sess-10"); got != slot { + t.Errorf("expected sess-10 at slot %d, got %d", slot, got) + } +} + +func TestNumberedSlots_StaleSlotReuse(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + + // Create 9 sessions and fill all slots + for i := 1; i <= 9; i++ { + name := fmt.Sprintf("sess-%d", i) + _ = store.AddSession(name, "main", fmt.Sprintf("/%d", i), map[string]int{}) + _, _ = store.AssignSlot(name) + } + + // Remove sess-3 from sessions but leave its slot (simulates stale slot) + delete(store.Sessions, "sess-3") + + // Add a new session + _ = store.AddSession("sess-new", "main", "/new", map[string]int{}) + + // Assign slot — should reuse the stale slot 3 + slot, err := store.AssignSlot("sess-new") + if err != nil { + t.Fatalf("failed to assign slot: %v", err) + } + if slot != 3 { + t.Errorf("expected stale slot 3 to be reused, got %d", slot) + } +} + +func TestNumberedSlots_GetSessionForSlot(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "devx-session-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", oldHome) + + store, _ := LoadSessions() + _ = store.AddSession("sess-a", "main", "/a", map[string]int{}) + _, _ = store.AssignSlot("sess-a") + + name := store.GetSessionForSlot(1) + if name != "sess-a" { + t.Errorf("expected 'sess-a' for slot 1, got '%s'", name) + } + + name = store.GetSessionForSlot(5) + if name != "" { + t.Errorf("expected empty for unassigned slot 5, got '%s'", name) + } +} diff --git a/tui/model.go b/tui/model.go index cfd3f7e..eceaae0 100644 --- a/tui/model.go +++ b/tui/model.go @@ -49,6 +49,12 @@ type projectItem struct { description string } +// displayEntry maps a session index to its position in the filtered view. +type displayEntry struct { + sessIdx int // index into model.sessions + filterIdx int // position in filtered view (-1 if not filtered) +} + type state int const ( @@ -108,6 +114,15 @@ type model struct { gitStatsTTLOthers time.Duration baseBranchTTL time.Duration maxStatsUpdatesPerCycle int + // MRU slots + numberedSlots map[int]string // slot number -> session name + slotsBootstrapped bool // true after first bootstrap pass + // Search/filter fields + filterActive bool + searchInput textinput.Model + searchFilter string + filteredIndices []int // indices into m.sessions that match the filter + searchCursor int // cursor within filtered results } // gitStatsEntry holds cached additions/deletions for a repo+branch @@ -133,6 +148,7 @@ type keyMap struct { Quit key.Binding Help key.Binding Back key.Binding + Search key.Binding } var keys = keyMap{ @@ -196,6 +212,10 @@ var keys = keyMap{ key.WithKeys("esc"), key.WithHelp("esc", "back"), ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search"), + ), } func (k keyMap) ShortHelp() []key.Binding { @@ -206,7 +226,7 @@ func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Up, k.Down, k.Enter}, {k.Create, k.Delete, k.Open}, - {k.Preview, k.Help, k.Quit}, + {k.Search, k.Preview, k.Help, k.Quit}, } } @@ -215,6 +235,10 @@ func InitialModel() *model { ti.Placeholder = "session-name" ti.CharLimit = 50 + si := textinput.New() + si.Placeholder = "search sessions..." + si.CharLimit = 50 + // Check for debug mode and level via environment variable debugEnv := os.Getenv("DEVX_DEBUG") debugMode := debugEnv != "" @@ -248,6 +272,7 @@ func InitialModel() *model { help: help.New(), keys: keys, textInput: ti, + searchInput: si, showPreview: true, // Enable preview by default width: 80, // Default width height: 24, // Default height @@ -268,6 +293,7 @@ func InitialModel() *model { gitStatsTTLOthers: 2 * time.Minute, baseBranchTTL: time.Hour, maxStatsUpdatesPerCycle: 5, + numberedSlots: make(map[int]string), } if debugMode { @@ -349,7 +375,7 @@ func (m *model) loadSessions() tea.Msg { return sessions[i].name < sessions[j].name }) - return sessionsLoadedMsg{sessions} + return sessionsLoadedMsg{sessions: sessions, store: store} } func (m *model) loadProjects() tea.Msg { @@ -378,6 +404,7 @@ func (m *model) loadProjects() tea.Msg { type sessionsLoadedMsg struct { sessions []sessionItem + store *session.SessionStore // carry store to avoid reloading for slots } type projectsLoadedMsg struct { @@ -462,10 +489,66 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state { case stateList: + // When filter is active, intercept keys for the search input + if m.filterActive { + switch { + case key.Matches(msg, m.keys.Back): // Esc + m.filterActive = false + m.searchInput.Blur() + m.searchFilter = "" + m.filteredIndices = nil + m.searchCursor = 0 + + case msg.Type == tea.KeyEnter: + // Jump cursor to selected filtered result, close filter + if len(m.filteredIndices) > 0 && m.searchCursor < len(m.filteredIndices) { + m.cursor = m.filteredIndices[m.searchCursor] + } + m.filterActive = false + m.searchInput.Blur() + m.searchFilter = "" + m.filteredIndices = nil + m.searchCursor = 0 + + case msg.Type == tea.KeyUp: + if m.searchCursor > 0 { + m.searchCursor-- + } + + case msg.Type == tea.KeyDown: + if len(m.filteredIndices) > 0 && m.searchCursor < len(m.filteredIndices)-1 { + m.searchCursor++ + } + + default: + var cmd tea.Cmd + m.searchInput, cmd = m.searchInput.Update(msg) + m.searchFilter = strings.ToLower(strings.TrimSpace(m.searchInput.Value())) + m.filteredIndices = nil + m.searchCursor = 0 + for i, sess := range m.sessions { + if m.searchFilter == "" || strings.Contains(strings.ToLower(sess.name), m.searchFilter) { + m.filteredIndices = append(m.filteredIndices, i) + } + } + return m, cmd + } + return m, nil + } + switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit + case key.Matches(msg, m.keys.Search): + m.filterActive = true + m.searchInput.Reset() + m.searchInput.Focus() + m.searchFilter = "" + m.filteredIndices = nil + m.searchCursor = 0 + return m, textinput.Blink + case key.Matches(msg, m.keys.Up): if m.cursor > 0 { m.cursor-- @@ -533,12 +616,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Help): m.help.ShowAll = !m.help.ShowAll - // Handle number keys 1-9 for quick navigation + // Handle number keys 1-9 for quick navigation (MRU slot-based) case msg.String() >= "1" && msg.String() <= "9": - // Convert to 0-based index - targetIndex := int(msg.String()[0] - '1') - if targetIndex < len(m.sessions) { - m.cursor = targetIndex + slot := int(msg.String()[0] - '0') + if sessName, ok := m.numberedSlots[slot]; ok { + if idx := m.sessionIndexByName(sessName); idx >= 0 { + m.cursor = idx + } } } @@ -762,6 +846,59 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Rebuild filteredIndices if filter is active so they stay in sync + if m.filterActive && m.searchFilter != "" { + m.filteredIndices = nil + for i, sess := range m.sessions { + if strings.Contains(strings.ToLower(sess.name), m.searchFilter) { + m.filteredIndices = append(m.filteredIndices, i) + } + } + if m.searchCursor >= len(m.filteredIndices) { + m.searchCursor = len(m.filteredIndices) - 1 + } + if m.searchCursor < 0 { + m.searchCursor = 0 + } + } + + // Load numbered slots from the store we already have + slotStore := msg.store + if slotStore != nil { + slotStore.ReconcileSlots() + // Bootstrap: fill any free slots (1-9) with unslotted sessions, + // but only on the first load to avoid repeated work every 2s. + if !m.slotsBootstrapped { + changed := false + // Sort session names for deterministic slot assignment + names := make([]string, 0, len(slotStore.Sessions)) + for name := range slotStore.Sessions { + names = append(names, name) + } + sort.Strings(names) + for i := 1; i <= 9; i++ { + if _, taken := slotStore.NumberedSlots[i]; taken { + continue + } + // Find an unslotted session to fill this free slot + for _, name := range names { + if slotStore.GetSlotForSession(name) == 0 { + slotStore.NumberedSlots[i] = name + changed = true + break + } + } + } + if changed { + _ = slotStore.Save() + } + m.slotsBootstrapped = true + } + m.numberedSlots = slotStore.NumberedSlots + } else { + m.numberedSlots = make(map[int]string) + } + // Schedule background git stats updates with TTLs (selected prioritized) var cmds []tea.Cmd now := time.Now() @@ -959,7 +1096,11 @@ func (m *model) View() string { } else { switch m.state { case stateList: - footer = footerStyle.Width(m.width).Render("↑/↓: navigate • 1-9: jump • enter: attach • c: create • d: delete • o: open routes • e: edit • h: hostnames • P: projects • p: preview • ?: help • q: quit") + if m.filterActive { + footer = footerStyle.Width(m.width).Render("↑/↓: navigate • enter: jump to session • esc: cancel search") + } else { + footer = footerStyle.Width(m.width).Render("↑/↓: navigate • 1-9: jump (MRU) • /: search • enter: attach • c: create • d: delete • o: open routes • e: edit • h: hostnames • P: projects • p: preview • ?: help • q: quit") + } case stateCreating: footer = footerStyle.Width(m.width).Render("enter: create session • esc: cancel") case stateProjectSelect: @@ -992,6 +1133,117 @@ func (m *model) View() string { Render(content) + "\n" + footer } +func (m *model) buildFilteredEntries() []displayEntry { + if m.filterActive && m.searchFilter != "" && m.filteredIndices != nil { + entries := make([]displayEntry, len(m.filteredIndices)) + for fi, si := range m.filteredIndices { + entries[fi] = displayEntry{sessIdx: si, filterIdx: fi} + } + return entries + } + entries := make([]displayEntry, len(m.sessions)) + for i := range m.sessions { + entries[i] = displayEntry{sessIdx: i, filterIdx: -1} + } + return entries +} + +func (m *model) renderSessionList(w *strings.Builder, entries []displayEntry, showDetails bool) { + if m.filterActive && m.searchFilter != "" && len(entries) == 0 { + w.WriteString(dimStyle.Render(" No matching sessions") + "\n") + return + } + + var currentProject string + for _, entry := range entries { + sess := m.sessions[entry.sessIdx] + + // Add project header if this is a new project + if sess.projectAlias != currentProject { + if currentProject != "" { + w.WriteString("\n") + } + currentProject = sess.projectAlias + + projectHeader := "No Project" + if sess.projectAlias != "" { + if sess.projectName != "" { + projectHeader = fmt.Sprintf("%s (%s)", sess.projectName, sess.projectAlias) + } else { + projectHeader = sess.projectAlias + } + } + + w.WriteString(headerStyle.Render(projectHeader) + "\n") + } + + // Cursor logic: use searchCursor when filtering, else main cursor + isSelected := false + if entry.filterIdx >= 0 { + isSelected = entry.filterIdx == m.searchCursor + } else { + isSelected = m.cursor == entry.sessIdx + } + + cursor := " " + if isSelected { + cursor = "> " + } + + // Add MRU slot number if this session has one + numberPrefix := " " + for slot, name := range m.numberedSlots { + if name == sess.name { + numberPrefix = fmt.Sprintf("%d. ", slot) + break + } + } + + // Add attention indicator + indicator := " " + if sess.attentionFlag { + indicator = "🔔" + } + + line := fmt.Sprintf("%s%s%s %s", cursor, numberPrefix, indicator, sess.name) + if isSelected { + line = selectedStyle.Render(line) + } + w.WriteString(line + "\n") + + // Show details for selected session (inline, only when not filtering) + if showDetails && !m.filterActive && m.cursor == entry.sessIdx { + details := m.getSessionDetails(sess) + lines := strings.Split(strings.TrimSuffix(details, "\n"), "\n") + for _, line := range lines { + w.WriteString(dimStyle.Render(line) + "\n") + } + } + } +} + +func (m *model) renderSearchBox(w *strings.Builder, availableWidth int) { + if m.filterActive { + w.WriteString("\n") + // Fit search box to available width + // Account for MarginLeft(2) + border(2) + padding(2) = 6 chars of overhead + boxWidth := availableWidth - 6 + if boxWidth > 40 { + boxWidth = 40 + } + if boxWidth < 15 { + boxWidth = 15 + } + searchBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("170")). + Padding(0, 1). + Width(boxWidth). + MarginLeft(2) + w.WriteString(searchBox.Render("/ " + m.searchInput.View())) + } +} + func (m *model) listView() string { logo := logoStyle.Width(m.width).Render(` ____ __ __ @@ -1026,63 +1278,9 @@ func (m *model) listView() string { b.WriteString(warningStyle.Render(m.caddyWarning) + "\n\n") } - // Group sessions by project for display - var currentProject string - for i, sess := range m.sessions { - // Add project header if this is a new project - if sess.projectAlias != currentProject { - if currentProject != "" { - b.WriteString("\n") // Add spacing between projects - } - currentProject = sess.projectAlias - - projectHeader := "No Project" - if sess.projectAlias != "" { - if sess.projectName != "" { - projectHeader = fmt.Sprintf("%s (%s)", sess.projectName, sess.projectAlias) - } else { - projectHeader = sess.projectAlias - } - } - - b.WriteString(headerStyle.Render(projectHeader) + "\n") - } - - cursor := " " - if m.cursor == i { - cursor = "> " - } - - // Add number shortcut for first 9 items - numberPrefix := "" - if i < 9 { - numberPrefix = fmt.Sprintf("%d. ", i+1) - } else { - numberPrefix = " " // Maintain alignment - } - - // Add attention indicator - indicator := " " - if sess.attentionFlag { - indicator = "🔔" - } - - line := fmt.Sprintf("%s%s%s %s", cursor, numberPrefix, indicator, sess.name) - if m.cursor == i { - line = selectedStyle.Render(line) - } - b.WriteString(line + "\n") - - // Show details for selected session (inline) - if m.cursor == i { - details := m.getSessionDetails(sess) - // Apply dimStyle to each line separately to avoid layout issues - lines := strings.Split(strings.TrimSuffix(details, "\n"), "\n") - for _, line := range lines { - b.WriteString(dimStyle.Render(line) + "\n") - } - } - } + entries := m.buildFilteredEntries() + m.renderSessionList(&b, entries, true) // true = show inline details + m.renderSearchBox(&b, m.width) return b.String() } @@ -1111,53 +1309,11 @@ func (m *model) listView() string { sessionList.WriteString(warningStyle.Render("⚠️ "+condensedWarning) + "\n\n") } - // Group sessions by project for display - var currentProject string - for i, sess := range m.sessions { - // Add project header if this is a new project - if sess.projectAlias != currentProject { - if currentProject != "" { - sessionList.WriteString("\n") // Add spacing between projects - } - currentProject = sess.projectAlias - - projectHeader := "No Project" - if sess.projectAlias != "" { - if sess.projectName != "" { - projectHeader = fmt.Sprintf("%s (%s)", sess.projectName, sess.projectAlias) - } else { - projectHeader = sess.projectAlias - } - } - - sessionList.WriteString(headerStyle.Render(projectHeader) + "\n") - } - - cursor := " " - if m.cursor == i { - cursor = "> " - } - - // Add number shortcut for first 9 items - numberPrefix := "" - if i < 9 { - numberPrefix = fmt.Sprintf("%d. ", i+1) - } else { - numberPrefix = " " // Maintain alignment - } - - // Add attention indicator - indicator := " " - if sess.attentionFlag { - indicator = "🔔" - } - - line := fmt.Sprintf("%s%s%s %s", cursor, numberPrefix, indicator, sess.name) - if m.cursor == i { - line = selectedStyle.Render(line) - } - sessionList.WriteString(line + "\n") - } + entries := m.buildFilteredEntries() + m.renderSessionList(&sessionList, entries, false) // false = no inline details + // In preview mode, the search box renders inside the left pane which has + // border(2) + padding(4) = 6 chars of overhead, so pass the inner width. + m.renderSearchBox(&sessionList, listWidth-6) // Build preview pane var preview string @@ -1718,6 +1874,16 @@ func (m *model) projectSelectView() string { return b.String() } +// sessionIndexByName returns the index of a session in m.sessions, or -1. +func (m *model) sessionIndexByName(name string) int { + for i, s := range m.sessions { + if s.name == name { + return i + } + } + return -1 +} + func (m *model) startSessionCreation() tea.Cmd { // Load projects to see if we need to show project selection return func() tea.Msg { @@ -1771,6 +1937,9 @@ func (m *model) attachSession(name string) tea.Cmd { tmuxExists := checkCmd.Run() == nil m.debugLogger.Printf("Session '%s' tmux status: exists=%t", name, tmuxExists) + // RecordAttach and AssignSlot are handled by the CLI command + // (devx session attach), so we don't duplicate them here. + cmd := attachCmd(name) m.debugLogger.Printf("Running attach command: %s %v", cmd.Path, cmd.Args)