From 9cddf9b710880aeb0b6bada1fafef1126e50182d Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sat, 14 Feb 2026 20:21:42 -0800 Subject: [PATCH 01/13] docs: add design for faster session jumping MRU-based slot-pinned numbering (1-9) and fuzzy search (/) to improve navigation when there are many sessions across projects. Co-Authored-By: Claude Opus 4.6 --- ...026-02-14-faster-session-jumping-design.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/plans/2026-02-14-faster-session-jumping-design.md 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` From 5af3145446e38978e9c0445b31decf5ac05d880e Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sat, 14 Feb 2026 20:23:58 -0800 Subject: [PATCH 02/13] docs: add implementation plan for faster session jumping 7-task plan covering MRU slot-pinned numbers, fuzzy search, and wiring into TUI display/key handling. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-14-faster-session-jumping.md | 812 ++++++++++++++++++ 1 file changed, 812 insertions(+) create mode 100644 docs/plans/2026-02-14-faster-session-jumping.md 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" +``` From 521ed300eee8766a163c89aaf494148edbe417d0 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sat, 14 Feb 2026 20:28:07 -0800 Subject: [PATCH 03/13] feat: add LastAttached timestamp to Session Add LastAttached field to Session struct and RecordAttach method to SessionStore, for tracking when sessions were last attached. Co-Authored-By: Claude Opus 4.6 --- session/metadata.go | 8 ++++++++ session/metadata_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/session/metadata.go b/session/metadata.go index 5f56a20..00a62ee 100644 --- a/session/metadata.go +++ b/session/metadata.go @@ -24,6 +24,7 @@ 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"` } @@ -140,6 +141,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 { diff --git a/session/metadata_test.go b/session/metadata_test.go index ef9b75f..0eaa066 100644 --- a/session/metadata_test.go +++ b/session/metadata_test.go @@ -120,3 +120,35 @@ 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") + } +} From 21c9421db44631b493e1397717ae07f6c4a3c57c Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sat, 14 Feb 2026 20:32:14 -0800 Subject: [PATCH 04/13] feat: add NumberedSlots with slot assignment and eviction logic Persistent map of slot (1-9) to session name on SessionStore. AssignSlot gives lowest free slot, keeps existing assignments stable, and evicts LRU (oldest LastAttached) when all 9 are full. Co-Authored-By: Claude Opus 4.6 --- session/metadata.go | 82 ++++++++++++++++++++++- session/metadata_test.go | 139 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 3 deletions(-) diff --git a/session/metadata.go b/session/metadata.go index 00a62ee..2e3f105 100644 --- a/session/metadata.go +++ b/session/metadata.go @@ -30,7 +30,8 @@ type Session struct { } 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 @@ -45,7 +46,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) @@ -61,6 +65,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 } @@ -165,7 +172,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() } @@ -312,3 +322,69 @@ 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 + 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) + } + } +} diff --git a/session/metadata_test.go b/session/metadata_test.go index 0eaa066..af69501 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) { @@ -152,3 +154,140 @@ func TestSessionLastAttached(t *testing.T) { 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 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) + } +} From ede79708ab3cf4eadf1114c2e9473b58b79cf8d0 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sat, 14 Feb 2026 20:38:29 -0800 Subject: [PATCH 05/13] feat: wire MRU slot numbers into attach flow and TUI display Record LastAttached and assign numbered slot on every attach (CLI and TUI). TUI loads/reconciles slots on session refresh, displays slot numbers instead of positional indices, and number keys jump to the session in that MRU slot. Co-Authored-By: Claude Opus 4.6 --- cmd/session_attach.go | 8 +++++ tui/model.go | 68 ++++++++++++++++++++++++++++++++----------- 2 files changed, 59 insertions(+), 17 deletions(-) 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/tui/model.go b/tui/model.go index cfd3f7e..51f2501 100644 --- a/tui/model.go +++ b/tui/model.go @@ -108,6 +108,8 @@ type model struct { gitStatsTTLOthers time.Duration baseBranchTTL time.Duration maxStatsUpdatesPerCycle int + // MRU slots + numberedSlots map[int]string // slot number -> session name } // gitStatsEntry holds cached additions/deletions for a repo+branch @@ -268,6 +270,7 @@ func InitialModel() *model { gitStatsTTLOthers: 2 * time.Minute, baseBranchTTL: time.Hour, maxStatsUpdatesPerCycle: 5, + numberedSlots: make(map[int]string), } if debugMode { @@ -533,12 +536,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 +766,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // 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) + } + // Schedule background git stats updates with TTLs (selected prioritized) var cmds []tea.Cmd now := time.Now() @@ -1053,12 +1067,13 @@ func (m *model) listView() string { cursor = "> " } - // Add number shortcut for first 9 items - numberPrefix := "" - if i < 9 { - numberPrefix = fmt.Sprintf("%d. ", i+1) - } else { - numberPrefix = " " // Maintain alignment + // 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 + } } // Add attention indicator @@ -1138,12 +1153,13 @@ func (m *model) listView() string { cursor = "> " } - // Add number shortcut for first 9 items - numberPrefix := "" - if i < 9 { - numberPrefix = fmt.Sprintf("%d. ", i+1) - } else { - numberPrefix = " " // Maintain alignment + // 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 + } } // Add attention indicator @@ -1718,6 +1734,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 +1797,14 @@ func (m *model) attachSession(name string) tea.Cmd { tmuxExists := checkCmd.Run() == nil m.debugLogger.Printf("Session '%s' tmux status: exists=%t", name, tmuxExists) + // 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) + } + cmd := attachCmd(name) m.debugLogger.Printf("Running attach command: %s %v", cmd.Path, cmd.Args) From 356f65ba929bcfe5802b4f002809e587ba887b47 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sat, 14 Feb 2026 20:41:59 -0800 Subject: [PATCH 06/13] feat: add fuzzy search mode with / key Press / in the session list to enter search mode. Type to filter sessions by name (case-insensitive substring), arrow keys to navigate filtered results, Enter to jump, Esc to cancel. Also updates footer to show MRU hint and /: search. Co-Authored-By: Claude Opus 4.6 --- tui/model.go | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/tui/model.go b/tui/model.go index 51f2501..9640bc9 100644 --- a/tui/model.go +++ b/tui/model.go @@ -59,6 +59,7 @@ const ( stateHostnames stateProjectManagement stateProjectAdd + stateSearch ) type model struct { @@ -110,6 +111,11 @@ type model struct { maxStatsUpdatesPerCycle int // MRU slots numberedSlots map[int]string // slot number -> session name + // Search fields + 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 @@ -135,6 +141,7 @@ type keyMap struct { Quit key.Binding Help key.Binding Back key.Binding + Search key.Binding } var keys = keyMap{ @@ -198,6 +205,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 { @@ -208,7 +219,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}, } } @@ -217,6 +228,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 != "" @@ -250,6 +265,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 @@ -469,6 +485,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Quit): return m, tea.Quit + 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 + case key.Matches(msg, m.keys.Up): if m.cursor > 0 { m.cursor-- @@ -746,6 +771,49 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textInput, cmd = m.textInput.Update(msg) return m, cmd } + + 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 + } } case sessionsLoadedMsg: @@ -964,6 +1032,8 @@ func (m *model) View() string { content = m.projectManagementView() case stateProjectAdd: content = m.projectAddView() + case stateSearch: + content = m.searchView() } // Create footer with commands @@ -973,7 +1043,7 @@ 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") + 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: @@ -986,6 +1056,8 @@ func (m *model) View() string { footer = footerStyle.Width(m.width).Render("↑/↓: navigate • c: add project • d: remove project • C: init Claude hooks • esc: back • q: quit") case stateProjectAdd: footer = footerStyle.Width(m.width).Render("enter: add project • esc: cancel") + case stateSearch: + footer = footerStyle.Width(m.width).Render("↑/↓: navigate • enter: jump to session • esc: cancel search") } } @@ -1734,6 +1806,77 @@ func (m *model) projectSelectView() string { return b.String() } +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() +} + // 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 { From d0a8a8556fb507fceb728efb3e2ccd405c298b42 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sat, 14 Feb 2026 20:49:22 -0800 Subject: [PATCH 07/13] fix: add attention indicator to search view, reconcile slots on rm Search view was missing the bell indicator for flagged sessions. Session removal now cleans up numbered slots immediately. Co-Authored-By: Claude Opus 4.6 --- cmd/session_rm.go | 4 ++++ tui/model.go | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/session_rm.go b/cmd/session_rm.go index d2ea570..bdbf460 100644 --- a/cmd/session_rm.go +++ b/cmd/session_rm.go @@ -80,6 +80,10 @@ func runSessionRm(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to remove session metadata: %w", err) } + // Clean up any numbered slot pointing to this session + store.ReconcileSlots() + _ = store.Save() + // Sync Caddy routes after removal if err := syncAllCaddyRoutes(); err != nil { fmt.Printf("Warning: failed to sync Caddy routes: %v\n", err) diff --git a/tui/model.go b/tui/model.go index 9640bc9..7868568 100644 --- a/tui/model.go +++ b/tui/model.go @@ -1857,7 +1857,13 @@ func (m *model) searchView() string { } } - line := fmt.Sprintf("%s%s %s", cursor, numberPrefix, sess.name) + // Add attention indicator + indicator := " " + if sess.attentionFlag { + indicator = "\U0001f514" + } + + line := fmt.Sprintf("%s%s%s %s", cursor, numberPrefix, indicator, sess.name) if filterIdx == m.searchCursor { line = selectedStyle.Render(line) } From 7c20ee16eb3e9624baef1f4a9f0b109931f491b8 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sun, 15 Feb 2026 06:23:31 -0800 Subject: [PATCH 08/13] docs: add design for inline search and slot bootstrap Co-Authored-By: Claude Opus 4.6 --- ...inline-search-and-slot-bootstrap-design.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/plans/2026-02-15-inline-search-and-slot-bootstrap-design.md 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 From 9e4a804b23a52bed4406bd6e7978462e796170b9 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sun, 15 Feb 2026 06:25:48 -0800 Subject: [PATCH 09/13] docs: add implementation plan for inline search and slot bootstrap Co-Authored-By: Claude Opus 4.6 --- ...5-inline-search-and-slot-bootstrap-plan.md | 400 ++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 docs/plans/2026-02-15-inline-search-and-slot-bootstrap-plan.md 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. From f6e8144492fad7797c6ad2b3e12f3f7861480ff1 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sun, 15 Feb 2026 06:42:26 -0800 Subject: [PATCH 10/13] feat: bootstrap MRU slots for all sessions on TUI load Co-Authored-By: Claude Opus 4.6 --- tui/model.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tui/model.go b/tui/model.go index 7868568..6f70b25 100644 --- a/tui/model.go +++ b/tui/model.go @@ -838,6 +838,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { From 38eb9f07f474991a54b13ac7aa8b9eff230a4af5 Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sun, 15 Feb 2026 06:47:07 -0800 Subject: [PATCH 11/13] feat: inline search filtering in main list view, remove separate search view Replace stateSearch with inline filtering that stays in the main list view. When pressing '/', a search box appears at the bottom and filters the list in place. Enter closes the filter and jumps to selected session. Esc cancels. Changes: - Remove stateSearch from state enum - Add filterActive field to model - Merge search key handling into stateList with filterActive guard - Update both preview and non-preview rendering to use displayEntry pattern - Remove searchView() function - Update footer to show appropriate help based on filterActive state Co-Authored-By: Claude Opus 4.6 --- tui/model.go | 428 ++++++++++++++++++++++++++------------------------- 1 file changed, 217 insertions(+), 211 deletions(-) diff --git a/tui/model.go b/tui/model.go index 6f70b25..766c141 100644 --- a/tui/model.go +++ b/tui/model.go @@ -59,7 +59,6 @@ const ( stateHostnames stateProjectManagement stateProjectAdd - stateSearch ) type model struct { @@ -111,7 +110,8 @@ type model struct { maxStatsUpdatesPerCycle int // MRU slots numberedSlots map[int]string // slot number -> session name - // Search fields + // Search/filter fields + filterActive bool searchInput textinput.Model searchFilter string filteredIndices []int // indices into m.sessions that match the filter @@ -481,12 +481,58 @@ 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 + + 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 { case key.Matches(msg, m.keys.Quit): return m, tea.Quit case key.Matches(msg, m.keys.Search): - m.state = stateSearch + m.filterActive = true m.searchInput.Reset() m.searchInput.Focus() m.searchFilter = "" @@ -771,49 +817,6 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textInput, cmd = m.textInput.Update(msg) return m, cmd } - - 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 - } } case sessionsLoadedMsg: @@ -1038,8 +1041,6 @@ func (m *model) View() string { content = m.projectManagementView() case stateProjectAdd: content = m.projectAddView() - case stateSearch: - content = m.searchView() } // Create footer with commands @@ -1049,7 +1050,11 @@ func (m *model) View() string { } else { switch m.state { case stateList: - 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") + 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: @@ -1062,8 +1067,6 @@ func (m *model) View() string { footer = footerStyle.Width(m.width).Render("↑/↓: navigate • c: add project • d: remove project • C: init Claude hooks • esc: back • q: quit") case stateProjectAdd: footer = footerStyle.Width(m.width).Render("enter: add project • esc: cancel") - case stateSearch: - footer = footerStyle.Width(m.width).Render("↑/↓: navigate • enter: jump to session • esc: cancel search") } } @@ -1118,65 +1121,107 @@ 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 + // Determine which sessions to display + type displayEntry struct { + sessIdx int // index into m.sessions + filterIdx int // position in filtered view (-1 if not filtered) + } - projectHeader := "No Project" - if sess.projectAlias != "" { - if sess.projectName != "" { - projectHeader = fmt.Sprintf("%s (%s)", sess.projectName, sess.projectAlias) - } else { - projectHeader = sess.projectAlias + 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}) + } + } + + if m.filterActive && m.searchFilter != "" && len(entries) == 0 { + b.WriteString(dimStyle.Render(" No matching sessions") + "\n") + } else { + // 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") } - b.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 m.cursor == i { - cursor = "> " - } + cursor := " " + if isSelected { + cursor = "> " + } - // 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 + // 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 = "🔔" - } + // 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") + line := fmt.Sprintf("%s%s%s %s", cursor, numberPrefix, indicator, sess.name) + if isSelected { + line = selectedStyle.Render(line) + } + b.WriteString(line + "\n") + + // Show details for selected session (inline, only when not filtering) + 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())) + } + return b.String() } @@ -1204,53 +1249,91 @@ 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 + // Determine which sessions to display + type displayEntry struct { + sessIdx int + filterIdx int + } - projectHeader := "No Project" - if sess.projectAlias != "" { - if sess.projectName != "" { - projectHeader = fmt.Sprintf("%s (%s)", sess.projectName, sess.projectAlias) - } else { - projectHeader = sess.projectAlias + 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}) + } + } + + if m.filterActive && m.searchFilter != "" && len(entries) == 0 { + sessionList.WriteString(dimStyle.Render(" No matching sessions") + "\n") + } else { + var currentProject string + for _, entry := range entries { + sess := m.sessions[entry.sessIdx] + + if sess.projectAlias != currentProject { + if currentProject != "" { + sessionList.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 + } } + + sessionList.WriteString(headerStyle.Render(projectHeader) + "\n") } - sessionList.WriteString(headerStyle.Render(projectHeader) + "\n") - } + isSelected := false + if entry.filterIdx >= 0 { + isSelected = entry.filterIdx == m.searchCursor + } else { + isSelected = m.cursor == entry.sessIdx + } - cursor := " " - if m.cursor == i { - cursor = "> " - } + cursor := " " + if isSelected { + cursor = "> " + } - // 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 + 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 = "🔔" - } + 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) + line := fmt.Sprintf("%s%s%s %s", cursor, numberPrefix, indicator, sess.name) + if isSelected { + line = selectedStyle.Render(line) + } + sessionList.WriteString(line + "\n") } - sessionList.WriteString(line + "\n") + } + + // Show search input at bottom when filter is active + if m.filterActive { + sessionList.WriteString("\n") + searchBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("170")). + Padding(0, 1). + Width(40). + MarginLeft(2) + sessionList.WriteString(searchBox.Render("/ " + m.searchInput.View())) } // Build preview pane @@ -1812,83 +1895,6 @@ func (m *model) projectSelectView() string { return b.String() } -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 - } - } - - // Add attention indicator - indicator := " " - if sess.attentionFlag { - indicator = "\U0001f514" - } - - line := fmt.Sprintf("%s%s%s %s", cursor, numberPrefix, indicator, 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() -} - // 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 { From e94fd60a34a5c17446a48e2668005c21d81909cc Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sun, 15 Feb 2026 06:56:25 -0800 Subject: [PATCH 12/13] refactor: extract display helpers, fix bootstrap performance - Short-circuit bootstrap loop when all 9 slots are full - Extract displayEntry type and helper methods (buildFilteredEntries, renderSessionList, renderSearchBox) - Remove code duplication between preview and non-preview paths - Reset searchCursor on Enter for defensive consistency Co-Authored-By: Claude Opus 4.6 --- tui/model.go | 309 ++++++++++++++++++++------------------------------- 1 file changed, 120 insertions(+), 189 deletions(-) diff --git a/tui/model.go b/tui/model.go index 766c141..ca13201 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 ( @@ -500,6 +506,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.searchInput.Blur() m.searchFilter = "" m.filteredIndices = nil + m.searchCursor = 0 case key.Matches(msg, m.keys.Up): if m.searchCursor > 0 { @@ -842,9 +849,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) + if len(slotStore.NumberedSlots) < 9 || len(slotStore.NumberedSlots) < len(slotStore.Sessions) { + for name := range slotStore.Sessions { + if slotStore.GetSlotForSession(name) == 0 { + _, _ = slotStore.AssignSlot(name) + } } } _ = slotStore.Save() @@ -1087,6 +1096,108 @@ 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) { + if m.filterActive { + w.WriteString("\n") + searchBox := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("170")). + Padding(0, 1). + Width(40). + MarginLeft(2) + w.WriteString(searchBox.Render("/ " + m.searchInput.View())) + } +} + func (m *model) listView() string { logo := logoStyle.Width(m.width).Render(` ____ __ __ @@ -1121,106 +1232,9 @@ func (m *model) listView() string { b.WriteString(warningStyle.Render(m.caddyWarning) + "\n\n") } - // 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}) - } - } - - if m.filterActive && m.searchFilter != "" && len(entries) == 0 { - b.WriteString(dimStyle.Render(" No matching sessions") + "\n") - } else { - // 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 - 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) - } - b.WriteString(line + "\n") - - // Show details for selected session (inline, only when not filtering) - 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())) - } + entries := m.buildFilteredEntries() + m.renderSessionList(&b, entries, true) // true = show inline details + m.renderSearchBox(&b) return b.String() } @@ -1249,92 +1263,9 @@ func (m *model) listView() string { sessionList.WriteString(warningStyle.Render("⚠️ "+condensedWarning) + "\n\n") } - // Determine which sessions to display - type displayEntry struct { - sessIdx int - filterIdx int - } - - 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}) - } - } - - if m.filterActive && m.searchFilter != "" && len(entries) == 0 { - sessionList.WriteString(dimStyle.Render(" No matching sessions") + "\n") - } else { - var currentProject string - for _, entry := range entries { - sess := m.sessions[entry.sessIdx] - - if sess.projectAlias != currentProject { - if currentProject != "" { - sessionList.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 - } - } - - sessionList.WriteString(headerStyle.Render(projectHeader) + "\n") - } - - isSelected := false - if entry.filterIdx >= 0 { - isSelected = entry.filterIdx == m.searchCursor - } else { - isSelected = m.cursor == entry.sessIdx - } - - cursor := " " - if isSelected { - cursor = "> " - } - - numberPrefix := " " - for slot, name := range m.numberedSlots { - if name == sess.name { - numberPrefix = fmt.Sprintf("%d. ", slot) - break - } - } - - indicator := " " - if sess.attentionFlag { - indicator = "🔔" - } - - line := fmt.Sprintf("%s%s%s %s", cursor, numberPrefix, indicator, sess.name) - if isSelected { - line = selectedStyle.Render(line) - } - sessionList.WriteString(line + "\n") - } - } - - // Show search input at bottom when filter is active - if m.filterActive { - sessionList.WriteString("\n") - searchBox := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("170")). - Padding(0, 1). - Width(40). - MarginLeft(2) - sessionList.WriteString(searchBox.Render("/ " + m.searchInput.View())) - } + entries := m.buildFilteredEntries() + m.renderSessionList(&sessionList, entries, false) // false = no inline details + m.renderSearchBox(&sessionList) // Build preview pane var preview string From db96341c6250de2f942f83e8a3157ed6ff421fbb Mon Sep 17 00:00:00 2001 From: Jon Fox Date: Sun, 15 Feb 2026 07:34:09 -0800 Subject: [PATCH 13/13] fix: address code review findings across slot logic and TUI - Fix stale filteredIndices when sessions refresh during active filter - Make eviction deterministic by sorting slots before tie-breaking - Eliminate double LoadSessions per refresh by passing store through msg - Remove duplicate RecordAttach/AssignSlot from TUI (CLI already does it) - Consolidate double Save in session_rm.go into single write - Gate bootstrap to run once on first load, not every 2s refresh - Add edge case tests: nonexistent session, all-zero eviction, stale slot Co-Authored-By: Claude Opus 4.6 --- cmd/session_rm.go | 12 ++--- session/metadata.go | 13 ++++- session/metadata_test.go | 112 +++++++++++++++++++++++++++++++++++++++ tui/model.go | 89 +++++++++++++++++++++++-------- 4 files changed, 194 insertions(+), 32 deletions(-) diff --git a/cmd/session_rm.go b/cmd/session_rm.go index bdbf460..839d074 100644 --- a/cmd/session_rm.go +++ b/cmd/session_rm.go @@ -75,14 +75,12 @@ 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) - } - - // Clean up any numbered slot pointing to this session + // Remove session from metadata and reconcile slots in a single save + delete(store.Sessions, name) store.ReconcileSlots() - _ = store.Save() + if err := store.Save(); err != nil { + return fmt.Errorf("failed to save session metadata: %w", err) + } // Sync Caddy routes after removal if err := syncAllCaddyRoutes(); err != nil { diff --git a/session/metadata.go b/session/metadata.go index 2e3f105..cf1829b 100644 --- a/session/metadata.go +++ b/session/metadata.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "time" @@ -345,10 +346,18 @@ func (s *SessionStore) AssignSlot(name string) (int, error) { } } - // All slots full — evict the session with the oldest LastAttached + // 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, sessName := range s.NumberedSlots { + for _, slot := range slots { + sessName := s.NumberedSlots[slot] sess, exists := s.Sessions[sessName] if !exists { // Stale slot, use it immediately diff --git a/session/metadata_test.go b/session/metadata_test.go index af69501..8dd0b3a 100644 --- a/session/metadata_test.go +++ b/session/metadata_test.go @@ -266,6 +266,118 @@ func TestNumberedSlots_Reconcile(t *testing.T) { } } +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 { diff --git a/tui/model.go b/tui/model.go index ca13201..eceaae0 100644 --- a/tui/model.go +++ b/tui/model.go @@ -115,7 +115,8 @@ type model struct { baseBranchTTL time.Duration maxStatsUpdatesPerCycle int // MRU slots - numberedSlots map[int]string // slot number -> session name + numberedSlots map[int]string // slot number -> session name + slotsBootstrapped bool // true after first bootstrap pass // Search/filter fields filterActive bool searchInput textinput.Model @@ -374,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 { @@ -403,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 { @@ -508,12 +510,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.filteredIndices = nil m.searchCursor = 0 - case key.Matches(msg, m.keys.Up): + case msg.Type == tea.KeyUp: if m.searchCursor > 0 { m.searchCursor-- } - case key.Matches(msg, m.keys.Down): + case msg.Type == tea.KeyDown: if len(m.filteredIndices) > 0 && m.searchCursor < len(m.filteredIndices)-1 { m.searchCursor++ } @@ -844,19 +846,54 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Load numbered slots - slotStore, slotErr := session.LoadSessions() - if slotErr == nil { + // 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: assign slots to any session that doesn't have one yet - if len(slotStore.NumberedSlots) < 9 || len(slotStore.NumberedSlots) < len(slotStore.Sessions) { + // 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 { - if slotStore.GetSlotForSession(name) == 0 { - _, _ = slotStore.AssignSlot(name) + 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 } - _ = slotStore.Save() m.numberedSlots = slotStore.NumberedSlots } else { m.numberedSlots = make(map[int]string) @@ -1185,14 +1222,23 @@ func (m *model) renderSessionList(w *strings.Builder, entries []displayEntry, sh } } -func (m *model) renderSearchBox(w *strings.Builder) { +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(40). + Width(boxWidth). MarginLeft(2) w.WriteString(searchBox.Render("/ " + m.searchInput.View())) } @@ -1234,7 +1280,7 @@ func (m *model) listView() string { entries := m.buildFilteredEntries() m.renderSessionList(&b, entries, true) // true = show inline details - m.renderSearchBox(&b) + m.renderSearchBox(&b, m.width) return b.String() } @@ -1265,7 +1311,9 @@ func (m *model) listView() string { entries := m.buildFilteredEntries() m.renderSessionList(&sessionList, entries, false) // false = no inline details - m.renderSearchBox(&sessionList) + // 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 @@ -1889,13 +1937,8 @@ func (m *model) attachSession(name string) tea.Cmd { tmuxExists := checkCmd.Run() == nil m.debugLogger.Printf("Session '%s' tmux status: exists=%t", name, tmuxExists) - // 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) - } + // 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)