From ce4242dfccda3cab0eeb1ef22ee23e3f07fe0e8a Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Tue, 20 Jan 2026 08:26:52 -0800 Subject: [PATCH 1/4] feat(project-switcher): add type-to-filter with bug fixes Add real-time text filtering to the project switcher modal (@): - Text input field with "Filter projects..." placeholder - Case-insensitive substring matching on name/path - Shows "X of Y projects" count when filtering - Shows "No matches" state when filter yields no results - Esc clears filter if set, otherwise closes modal Bug fixes applied during review: - Add j/k keyboard navigation (was missing, forwarded to textinput) - Add updateContext() call to early Esc handler for consistency - Clear hover state when filter changes to prevent invalid index Implements td-33747ce6 Fixes td-898b360e, td-3c955030, td-dbc2a106 Co-Authored-By: Claude Opus 4.5 --- internal/app/model.go | 63 +++++++++++++++-- internal/app/update.go | 154 +++++++++++++++++++++++++++++------------ internal/app/view.go | 67 +++++++++++++----- 3 files changed, 219 insertions(+), 65 deletions(-) diff --git a/internal/app/model.go b/internal/app/model.go index 951e5a08..2654a728 100644 --- a/internal/app/model.go +++ b/internal/app/model.go @@ -3,8 +3,10 @@ package app import ( "fmt" "os/exec" + "strings" "time" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/marcus/sidecar/internal/config" "github.com/marcus/sidecar/internal/keymap" @@ -43,10 +45,12 @@ type Model struct { palette palette.Model // Project switcher modal - showProjectSwitcher bool - projectSwitcherCursor int - projectSwitcherScroll int - projectSwitcherHover int // -1 = no hover, 0+ = hovered project index + showProjectSwitcher bool + projectSwitcherCursor int + projectSwitcherScroll int + projectSwitcherHover int // -1 = no hover, 0+ = hovered project index + projectSwitcherInput textinput.Model + projectSwitcherFiltered []config.ProjectConfig // Header/footer ui *UIState @@ -346,6 +350,57 @@ func (m *Model) resetProjectSwitcher() { m.projectSwitcherCursor = 0 m.projectSwitcherScroll = 0 m.projectSwitcherHover = -1 + m.projectSwitcherFiltered = nil +} + +// initProjectSwitcher initializes the project switcher modal. +func (m *Model) initProjectSwitcher() { + ti := textinput.New() + ti.Placeholder = "Filter projects..." + ti.Focus() + ti.CharLimit = 50 + ti.Width = 40 + m.projectSwitcherInput = ti + m.projectSwitcherFiltered = m.cfg.Projects.List + m.projectSwitcherCursor = 0 + m.projectSwitcherScroll = 0 + m.projectSwitcherHover = -1 + + // Set cursor to current project if found + for i, proj := range m.projectSwitcherFiltered { + if proj.Path == m.ui.WorkDir { + m.projectSwitcherCursor = i + break + } + } +} + +// filterProjects filters projects by name or path using a case-insensitive substring match. +func filterProjects(all []config.ProjectConfig, query string) []config.ProjectConfig { + if query == "" { + return all + } + q := strings.ToLower(query) + var matches []config.ProjectConfig + for _, p := range all { + if strings.Contains(strings.ToLower(p.Name), q) || + strings.Contains(strings.ToLower(p.Path), q) { + matches = append(matches, p) + } + } + return matches +} + +// projectSwitcherEnsureCursorVisible adjusts scroll to keep cursor in view. +// Returns the new scroll offset. +func projectSwitcherEnsureCursorVisible(cursor, scroll, maxVisible int) int { + if cursor < scroll { + return cursor + } + if cursor >= scroll+maxVisible { + return cursor - maxVisible + 1 + } + return scroll } // switchProject switches all plugins to a new project directory. diff --git a/internal/app/update.go b/internal/app/update.go index 5b4f8514..0113983b 100644 --- a/internal/app/update.go +++ b/internal/app/update.go @@ -284,6 +284,7 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } if m.showProjectSwitcher { m.resetProjectSwitcher() + m.updateContext() return m, nil } } @@ -384,50 +385,108 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Handle project switcher modal keys if m.showProjectSwitcher { - projects := m.cfg.Projects.List - if len(projects) == 0 { + allProjects := m.cfg.Projects.List + if len(allProjects) == 0 { // No projects configured, just close on any key - if msg.String() == "q" || msg.String() == "@" { + if msg.String() == "q" || msg.String() == "@" || msg.Type == tea.KeyEsc { m.resetProjectSwitcher() + m.updateContext() } return m, nil } - switch msg.String() { - case "j", "down": - m.projectSwitcherCursor++ - if m.projectSwitcherCursor >= len(projects) { - m.projectSwitcherCursor = len(projects) - 1 + projects := m.projectSwitcherFiltered + + switch msg.Type { + case tea.KeyEsc: + // Esc: clear filter if set, otherwise close modal + if m.projectSwitcherInput.Value() != "" { + m.projectSwitcherInput.SetValue("") + m.projectSwitcherFiltered = allProjects + m.projectSwitcherCursor = 0 + m.projectSwitcherScroll = 0 + return m, nil + } + m.resetProjectSwitcher() + m.updateContext() + return m, nil + + case tea.KeyEnter: + // Select project and switch to it + if m.projectSwitcherCursor >= 0 && m.projectSwitcherCursor < len(projects) { + selectedProject := projects[m.projectSwitcherCursor] + m.resetProjectSwitcher() + m.updateContext() + return m, m.switchProject(selectedProject.Path) } return m, nil - case "k", "up": + + case tea.KeyUp: m.projectSwitcherCursor-- if m.projectSwitcherCursor < 0 { m.projectSwitcherCursor = 0 } + m.projectSwitcherScroll = projectSwitcherEnsureCursorVisible(m.projectSwitcherCursor, m.projectSwitcherScroll, 8) return m, nil - case "g": - // Handle g g sequence (jump to top) - m.projectSwitcherCursor = 0 + + case tea.KeyDown: + m.projectSwitcherCursor++ + if m.projectSwitcherCursor >= len(projects) { + m.projectSwitcherCursor = len(projects) - 1 + } + if m.projectSwitcherCursor < 0 { + m.projectSwitcherCursor = 0 + } + m.projectSwitcherScroll = projectSwitcherEnsureCursorVisible(m.projectSwitcherCursor, m.projectSwitcherScroll, 8) return m, nil - case "G": - // Jump to bottom - m.projectSwitcherCursor = len(projects) - 1 + } + + // Handle string-based keys + switch msg.String() { + case "j", "ctrl+n": + m.projectSwitcherCursor++ + if m.projectSwitcherCursor >= len(projects) { + m.projectSwitcherCursor = len(projects) - 1 + } + if m.projectSwitcherCursor < 0 { + m.projectSwitcherCursor = 0 + } + m.projectSwitcherScroll = projectSwitcherEnsureCursorVisible(m.projectSwitcherCursor, m.projectSwitcherScroll, 8) return m, nil - case "enter": - // Select project and switch to it - if m.projectSwitcherCursor >= 0 && m.projectSwitcherCursor < len(projects) { - selectedProject := projects[m.projectSwitcherCursor] - m.resetProjectSwitcher() - return m, m.switchProject(selectedProject.Path) + + case "k", "ctrl+p": + m.projectSwitcherCursor-- + if m.projectSwitcherCursor < 0 { + m.projectSwitcherCursor = 0 } + m.projectSwitcherScroll = projectSwitcherEnsureCursorVisible(m.projectSwitcherCursor, m.projectSwitcherScroll, 8) return m, nil - case "q", "@": + + case "@": // Close modal m.resetProjectSwitcher() + m.updateContext() return m, nil } - return m, nil + + // Forward other keys to text input for filtering + var cmd tea.Cmd + m.projectSwitcherInput, cmd = m.projectSwitcherInput.Update(msg) + + // Re-filter on input change + m.projectSwitcherFiltered = filterProjects(allProjects, m.projectSwitcherInput.Value()) + m.projectSwitcherHover = -1 // Clear hover on filter change + // Reset cursor if it's beyond filtered list + if m.projectSwitcherCursor >= len(m.projectSwitcherFiltered) { + m.projectSwitcherCursor = len(m.projectSwitcherFiltered) - 1 + } + if m.projectSwitcherCursor < 0 { + m.projectSwitcherCursor = 0 + } + m.projectSwitcherScroll = 0 + m.projectSwitcherScroll = projectSwitcherEnsureCursorVisible(m.projectSwitcherCursor, m.projectSwitcherScroll, 8) + + return m, cmd } // If modal is open, don't process other keys @@ -495,14 +554,7 @@ func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.showProjectSwitcher = !m.showProjectSwitcher if m.showProjectSwitcher { m.activeContext = "project-switcher" - // Reset cursor to current project if possible - m.projectSwitcherCursor = 0 - for i, proj := range m.cfg.Projects.List { - if proj.Path == m.ui.WorkDir { - m.projectSwitcherCursor = i - break - } - } + m.initProjectSwitcher() } else { m.resetProjectSwitcher() m.updateContext() @@ -645,15 +697,19 @@ func isGlobalRefreshContext(ctx string) bool { // handleProjectSwitcherMouse handles mouse events for the project switcher modal. func (m Model) handleProjectSwitcherMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - projects := m.cfg.Projects.List - if len(projects) == 0 { + allProjects := m.cfg.Projects.List + if len(allProjects) == 0 { // No projects, close on click if msg.Action == tea.MouseActionPress { m.resetProjectSwitcher() + m.updateContext() } return m, nil } + // Use filtered list + projects := m.projectSwitcherFiltered + // Calculate modal dimensions and position // This should roughly match the modal rendered in view.go maxVisible := 8 @@ -662,15 +718,19 @@ func (m Model) handleProjectSwitcherMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) visibleCount = maxVisible } - // Estimate modal dimensions (title + projects + help text) - // Each project takes 2 lines (name + path) - modalContentLines := 2 + visibleCount*2 + 2 // title+space + projects + space+help + // Estimate modal dimensions (title + input + count + projects + help text) + // Title: 2 lines, Input: 1 line, Count: 1 line, Projects: 2 lines each, Help: 2 lines + modalContentLines := 2 + 1 + 1 + visibleCount*2 + 2 if m.projectSwitcherScroll > 0 { modalContentLines++ // scroll indicator above } if len(projects) > m.projectSwitcherScroll+visibleCount { modalContentLines++ // scroll indicator below } + // Empty state takes less space + if len(projects) == 0 { + modalContentLines = 2 + 1 + 1 + 2 + 2 // title + input + count + "no matches" + help + } // ModalBox adds padding and border (~2 on each side) modalHeight := modalContentLines + 4 @@ -683,10 +743,15 @@ func (m Model) handleProjectSwitcherMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) if msg.X >= modalX && msg.X < modalX+modalWidth && msg.Y >= modalY && msg.Y < modalY+modalHeight { + // If no filtered projects, don't try to select + if len(projects) == 0 { + return m, nil + } + // Calculate which project was clicked // Content starts at modalY + 2 (border + padding) - // Title takes 2 lines, then scroll indicator (if any), then projects - contentStartY := modalY + 2 + 2 // border/padding + title + // Title: 2 lines, Input: 1 line, Count: 1 line, then scroll indicator (if any), then projects + contentStartY := modalY + 2 + 2 + 1 + 1 // border/padding + title + input + count if m.projectSwitcherScroll > 0 { contentStartY++ // scroll indicator } @@ -703,6 +768,7 @@ func (m Model) handleProjectSwitcherMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) // Click to select and switch selectedProject := projects[projectIdx] m.resetProjectSwitcher() + m.updateContext() return m, m.switchProject(selectedProject.Path) } case tea.MouseActionMotion: @@ -725,18 +791,17 @@ func (m Model) handleProjectSwitcherMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) m.projectSwitcherCursor = 0 } // Update scroll if cursor goes above visible area - if m.projectSwitcherCursor < m.projectSwitcherScroll { - m.projectSwitcherScroll = m.projectSwitcherCursor - } + m.projectSwitcherScroll = projectSwitcherEnsureCursorVisible(m.projectSwitcherCursor, m.projectSwitcherScroll, maxVisible) case tea.MouseButtonWheelDown: m.projectSwitcherCursor++ if m.projectSwitcherCursor >= len(projects) { m.projectSwitcherCursor = len(projects) - 1 } - // Update scroll if cursor goes below visible area - if m.projectSwitcherCursor >= m.projectSwitcherScroll+visibleCount { - m.projectSwitcherScroll = m.projectSwitcherCursor - visibleCount + 1 + if m.projectSwitcherCursor < 0 { + m.projectSwitcherCursor = 0 } + // Update scroll if cursor goes below visible area + m.projectSwitcherScroll = projectSwitcherEnsureCursorVisible(m.projectSwitcherCursor, m.projectSwitcherScroll, maxVisible) } return m, nil @@ -745,6 +810,7 @@ func (m Model) handleProjectSwitcherMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) // Click outside modal - close it if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { m.resetProjectSwitcher() + m.updateContext() return m, nil } diff --git a/internal/app/view.go b/internal/app/view.go index 79012795..36e5c069 100644 --- a/internal/app/view.go +++ b/internal/app/view.go @@ -115,10 +115,10 @@ func (m Model) renderProjectSwitcherOverlay(content string) string { b.WriteString(styles.Muted.Render("@")) b.WriteString("\n\n") - projects := m.cfg.Projects.List + allProjects := m.cfg.Projects.List - // Empty state - if len(projects) == 0 { + // Empty state (no projects configured at all) + if len(allProjects) == 0 { b.WriteString(styles.Muted.Render("No projects configured.\n\n")) b.WriteString(styles.Muted.Render("Add projects to ")) b.WriteString(styles.KeyHint.Render("~/.config/sidecar/config.json")) @@ -138,6 +138,31 @@ func (m Model) renderProjectSwitcherOverlay(content string) string { return ui.OverlayModal(content, modal, m.width, m.height) } + // Render search input + b.WriteString(m.projectSwitcherInput.View()) + b.WriteString("\n") + + // Show count if filtering + projects := m.projectSwitcherFiltered + if m.projectSwitcherInput.Value() != "" { + b.WriteString(styles.Muted.Render(fmt.Sprintf("%d of %d projects", len(projects), len(allProjects)))) + } + b.WriteString("\n") + + // Empty filtered state + if len(projects) == 0 { + b.WriteString("\n") + b.WriteString(styles.Muted.Render("No matches")) + b.WriteString("\n\n") + b.WriteString(styles.KeyHint.Render("esc")) + b.WriteString(styles.Muted.Render(" clear filter ")) + b.WriteString(styles.KeyHint.Render("@")) + b.WriteString(styles.Muted.Render(" close")) + + modal := styles.ModalBox.Render(b.String()) + return ui.OverlayModal(content, modal, m.width, m.height) + } + // Calculate visible window for scrolling maxVisible := 8 visibleCount := len(projects) @@ -145,14 +170,8 @@ func (m Model) renderProjectSwitcherOverlay(content string) string { visibleCount = maxVisible } - // Ensure cursor is visible within scroll window + // Use stored scroll offset scrollOffset := m.projectSwitcherScroll - if m.projectSwitcherCursor < scrollOffset { - scrollOffset = m.projectSwitcherCursor - } - if m.projectSwitcherCursor >= scrollOffset+visibleCount { - scrollOffset = m.projectSwitcherCursor - visibleCount + 1 - } // Render scroll indicator if needed (top) if scrollOffset > 0 { @@ -161,9 +180,17 @@ func (m Model) renderProjectSwitcherOverlay(content string) string { // Styles for project items cursorStyle := lipgloss.NewStyle().Foreground(styles.Primary) + // Normal name: themed color (secondary/blue) for visibility + nameNormalStyle := lipgloss.NewStyle().Foreground(styles.Secondary) + // Selected name: brighter primary color + bold nameSelectedStyle := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true) + // Current project: green + bold nameCurrentStyle := lipgloss.NewStyle().Foreground(styles.Success).Bold(true) - pathSelectedStyle := styles.Muted.Bold(true) + // Current + selected: bright green + bold + nameCurrentSelectedStyle := lipgloss.NewStyle().Foreground(styles.Success).Bold(true) + // Paths: always muted, never bold (slightly brighter when selected) + pathNormalStyle := styles.Subtle + pathSelectedStyle := styles.Muted // Render project list for i := scrollOffset; i < scrollOffset+visibleCount && i < len(projects); i++ { @@ -179,12 +206,18 @@ func (m Model) renderProjectSwitcherOverlay(content string) string { b.WriteString(" ") } - // Project name - nameStyle := styles.BarText + // Project name - always has themed color, bold when selected + var nameStyle lipgloss.Style if isCurrent { - nameStyle = nameCurrentStyle + if isCursor || isHover { + nameStyle = nameCurrentSelectedStyle + } else { + nameStyle = nameCurrentStyle + } } else if isCursor || isHover { nameStyle = nameSelectedStyle + } else { + nameStyle = nameNormalStyle } b.WriteString(nameStyle.Render(proj.Name)) @@ -194,8 +227,8 @@ func (m Model) renderProjectSwitcherOverlay(content string) string { } b.WriteString("\n") - // Project path (muted, indented) - pathStyle := styles.Muted + // Project path (always non-bold, slightly brighter when selected) + pathStyle := pathNormalStyle if isCursor || isHover { pathStyle = pathSelectedStyle } @@ -215,7 +248,7 @@ func (m Model) renderProjectSwitcherOverlay(content string) string { // Help text b.WriteString(styles.KeyHint.Render("enter")) b.WriteString(styles.Muted.Render(" select ")) - b.WriteString(styles.KeyHint.Render("j/k")) + b.WriteString(styles.KeyHint.Render("↑/↓")) b.WriteString(styles.Muted.Render(" navigate ")) b.WriteString(styles.KeyHint.Render("esc")) b.WriteString(styles.Muted.Render(" cancel")) From 4e53067986a2e2ea115e5002600d64d1b0b724d8 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Tue, 20 Jan 2026 08:30:18 -0800 Subject: [PATCH 2/4] docs: add project switcher developer guide Covers architecture, state management, keyboard/mouse handling, filtering, view rendering, and extension points. Co-Authored-By: Claude Opus 4.5 --- docs/guides/project-switcher-dev-guide.md | 432 ++++++++++++++++++++++ 1 file changed, 432 insertions(+) create mode 100644 docs/guides/project-switcher-dev-guide.md diff --git a/docs/guides/project-switcher-dev-guide.md b/docs/guides/project-switcher-dev-guide.md new file mode 100644 index 00000000..29041317 --- /dev/null +++ b/docs/guides/project-switcher-dev-guide.md @@ -0,0 +1,432 @@ +# Project Switcher Developer Guide + +Implementation guide for the project switcher modal (`@` hotkey). + +## Architecture Overview + +The project switcher is an app-level modal in `internal/app/`. It consists of: + +| Component | File | Purpose | +|-----------|------|---------| +| Model state | `model.go:47-53` | Modal visibility, cursor, scroll, filter | +| Init/reset | `model.go:346-404` | State initialization and cleanup | +| Keyboard | `update.go:385-488` | Key event handling | +| Mouse | `update.go:697-811` | Click, scroll, hover | +| View | `view.go:108-258` | Modal rendering | +| Project switch | `model.go:406-440` | Plugin context reinit | + +## Model State + +```go +// internal/app/model.go + +// Project switcher modal +showProjectSwitcher bool // Modal visibility +projectSwitcherCursor int // Selected index in filtered list +projectSwitcherScroll int // Scroll offset for long lists +projectSwitcherHover int // Mouse hover index (-1 = none) +projectSwitcherInput textinput.Model // Filter text input +projectSwitcherFiltered []config.ProjectConfig // Filtered project list +``` + +## Initialization + +### Opening the Modal + +When `@` is pressed (`update.go:554`): + +```go +case "@": + m.showProjectSwitcher = !m.showProjectSwitcher + if m.showProjectSwitcher { + m.activeContext = "project-switcher" + m.initProjectSwitcher() + } else { + m.resetProjectSwitcher() + m.updateContext() + } +``` + +### initProjectSwitcher() + +`model.go:356-376` - Sets up the modal state: + +```go +func (m *Model) initProjectSwitcher() { + ti := textinput.New() + ti.Placeholder = "Filter projects..." + ti.Focus() + ti.CharLimit = 50 + ti.Width = 40 + m.projectSwitcherInput = ti + m.projectSwitcherFiltered = m.cfg.Projects.List + m.projectSwitcherCursor = 0 + m.projectSwitcherScroll = 0 + m.projectSwitcherHover = -1 + + // Pre-select current project + for i, proj := range m.projectSwitcherFiltered { + if proj.Path == m.ui.WorkDir { + m.projectSwitcherCursor = i + break + } + } +} +``` + +### resetProjectSwitcher() + +`model.go:346-354` - Cleans up when modal closes: + +```go +func (m *Model) resetProjectSwitcher() { + m.showProjectSwitcher = false + m.activeContext = "" + m.projectSwitcherInput = textinput.Model{} + m.projectSwitcherCursor = 0 + m.projectSwitcherScroll = 0 + m.projectSwitcherHover = -1 + m.projectSwitcherFiltered = nil +} +``` + +## Keyboard Handling + +All keyboard logic is in `update.go:385-488`. + +### Key Priority + +1. **KeyType switch** (`msg.Type`) - Handles special keys: + - `KeyEsc` - Clear filter or close modal + - `KeyEnter` - Select project + - `KeyUp/KeyDown` - Arrow navigation + +2. **String switch** (`msg.String()`) - Handles named keys: + - `j/k` - Vim-style navigation + - `ctrl+n/ctrl+p` - Emacs-style navigation + - `@` - Close modal + +3. **Fallthrough** - All other keys forwarded to textinput + +### Esc Behavior + +Esc has two behaviors (`update.go:400-411`): + +```go +case tea.KeyEsc: + // Clear filter if set + if m.projectSwitcherInput.Value() != "" { + m.projectSwitcherInput.SetValue("") + m.projectSwitcherFiltered = allProjects + m.projectSwitcherCursor = 0 + m.projectSwitcherScroll = 0 + return m, nil + } + // Otherwise close modal + m.resetProjectSwitcher() + m.updateContext() + return m, nil +``` + +### Navigation with Scroll + +Navigation updates cursor and ensures visibility (`update.go:423-440`): + +```go +case tea.KeyDown: + m.projectSwitcherCursor++ + if m.projectSwitcherCursor >= len(projects) { + m.projectSwitcherCursor = len(projects) - 1 + } + if m.projectSwitcherCursor < 0 { + m.projectSwitcherCursor = 0 + } + m.projectSwitcherScroll = projectSwitcherEnsureCursorVisible( + m.projectSwitcherCursor, m.projectSwitcherScroll, 8) + return m, nil +``` + +### Filter Input + +Keys not matching special cases go to textinput (`update.go:471-486`): + +```go +// Forward to text input +var cmd tea.Cmd +m.projectSwitcherInput, cmd = m.projectSwitcherInput.Update(msg) + +// Re-filter on change +m.projectSwitcherFiltered = filterProjects(allProjects, m.projectSwitcherInput.Value()) +m.projectSwitcherHover = -1 // Clear hover on filter change + +// Clamp cursor to valid range +if m.projectSwitcherCursor >= len(m.projectSwitcherFiltered) { + m.projectSwitcherCursor = len(m.projectSwitcherFiltered) - 1 +} +if m.projectSwitcherCursor < 0 { + m.projectSwitcherCursor = 0 +} +``` + +## Filtering + +### filterProjects() + +`model.go:378-392` - Case-insensitive substring match: + +```go +func filterProjects(all []config.ProjectConfig, query string) []config.ProjectConfig { + if query == "" { + return all + } + q := strings.ToLower(query) + var matches []config.ProjectConfig + for _, p := range all { + if strings.Contains(strings.ToLower(p.Name), q) || + strings.Contains(strings.ToLower(p.Path), q) { + matches = append(matches, p) + } + } + return matches +} +``` + +Searches both `Name` and `Path` fields. + +### Scroll Helper + +`model.go:394-404` - Keeps cursor in visible window: + +```go +func projectSwitcherEnsureCursorVisible(cursor, scroll, maxVisible int) int { + if cursor < scroll { + return cursor + } + if cursor >= scroll+maxVisible { + return cursor - maxVisible + 1 + } + return scroll +} +``` + +## Mouse Handling + +Mouse logic is in `update.go:697-811`. + +### Layout Calculation + +The modal layout for hit detection (`update.go:718-740`): + +```go +// Modal content lines: title + input + count + projects + help +modalContentLines := 2 + 1 + 1 + visibleCount*2 + 2 +if m.projectSwitcherScroll > 0 { + modalContentLines++ // scroll indicator above +} +if len(projects) > m.projectSwitcherScroll+visibleCount { + modalContentLines++ // scroll indicator below +} + +// ModalBox adds padding and border (~2 on each side) +modalHeight := modalContentLines + 4 +modalWidth := 50 +modalX := (m.width - modalWidth) / 2 +modalY := (m.height - modalHeight) / 2 +``` + +### Click Detection + +Project click detection (`update.go:749-776`): + +```go +// Content starts at modalY + 2 (border + padding) +// Title: 2 lines, Input: 1 line, Count: 1 line +contentStartY := modalY + 2 + 2 + 1 + 1 +if m.projectSwitcherScroll > 0 { + contentStartY++ // scroll indicator +} + +// Each project takes 2 lines (name + path) +relY := msg.Y - contentStartY +if relY >= 0 && relY < visibleCount*2 { + projectIdx := m.projectSwitcherScroll + relY/2 + // Handle click... +} +``` + +### Hover State + +Mouse motion updates hover index (`update.go:772-781`): + +```go +case tea.MouseActionMotion: + m.projectSwitcherHover = projectIdx +``` + +Hover is cleared when: +- Mouse moves outside project list area +- Filter changes (ensures no invalid index) +- Modal closes + +### Scroll Wheel + +Wheel events move cursor and scroll (`update.go:784-804`): + +```go +case tea.MouseButtonWheelUp: + m.projectSwitcherCursor-- + // clamp and update scroll +case tea.MouseButtonWheelDown: + m.projectSwitcherCursor++ + // clamp and update scroll +``` + +## View Rendering + +View logic is in `view.go:108-258`. + +### Modal Structure + +``` +┌─────────────────────────────────────────┐ +│ Switch Project @ │ <- Title (2 lines) +│ │ +│ [Filter projects... ] │ <- Input (1 line) +│ 3 of 10 projects │ <- Count (1 line, only when filtering) +│ ↑ 2 more above │ <- Scroll indicator (optional) +│ → sidecar │ <- Project name (cursor/hover) +│ ~/code/sidecar │ <- Project path +│ td (current) │ <- Current project (green) +│ ~/code/td │ +│ ↓ 5 more below │ <- Scroll indicator (optional) +│ │ +│ esc clear @ close │ <- Help hints +└─────────────────────────────────────────┘ +``` + +### Empty States + +Two empty states exist: + +1. **No projects configured** (`view.go:120-139`) - Shows config example +2. **No filter matches** (`view.go:152-164`) - Shows "No matches" with hints + +### Project Item Styling + +Each project has conditional styling (`view.go:209-227`): + +| State | Name Style | Path Style | +|-------|------------|------------| +| Normal | Secondary (blue) | Subtle | +| Cursor/Hover | Primary + Bold | Muted | +| Current | Success (green) + Bold | Subtle | +| Current + Selected | Success + Bold | Muted | + +Current project shows "(current)" label. + +### Scroll Indicators + +Show when list overflows (`view.go:176-179`, `247-249`): + +```go +if scrollOffset > 0 { + b.WriteString(styles.Muted.Render(fmt.Sprintf(" ↑ %d more above\n", scrollOffset))) +} +// ... render projects ... +if remaining > 0 { + b.WriteString(styles.Muted.Render(fmt.Sprintf(" ↓ %d more below\n", remaining))) +} +``` + +## Project Switching + +### switchProject() + +`model.go:406-440` - Handles the actual switch: + +```go +func (m *Model) switchProject(projectPath string) tea.Cmd { + // Skip if same project + if projectPath == m.ui.WorkDir { + return m.toast("Already on this project") + } + + return func() tea.Msg { + // 1. Stop all plugins + m.registry.Stop() + + // 2. Update working directory + m.ui.WorkDir = projectPath + + // 3. Reinitialize plugins + m.registry.Reinit(plugin.Context{ + WorkDir: projectPath, + // ... + }) + + // 4. Restore active plugin for this project + // 5. Show toast notification + + return ProjectSwitchedMsg{Path: projectPath} + } +} +``` + +### Plugin Reinitialization + +When switching projects, plugins receive a new `Init()` call. Plugins must reset their state - see `internal/plugins/worktree/plugin.go:259-265` for an example of proper state reset. + +## Adding New Features + +### Adding a Keyboard Shortcut + +1. Add case in `update.go` string switch (after KeyType switch) +2. Return early to prevent textinput forwarding + +```go +case "ctrl+d": + // Custom action + return m, nil +``` + +### Adding Project Metadata + +1. Extend `config.ProjectConfig` in `internal/config/types.go` +2. Update `filterProjects()` to search new fields +3. Update view rendering to display new fields + +### Changing Filter Algorithm + +Replace `filterProjects()` body. Current: substring match. Options: +- Fuzzy matching (like command palette) +- Regex support +- Field-specific search (`name:foo`) + +## Testing + +Currently no dedicated tests exist for the project switcher. Recommended test coverage: + +1. **filterProjects()** - Various query inputs +2. **projectSwitcherEnsureCursorVisible()** - Scroll boundary cases +3. **Keyboard navigation** - Cursor bounds, scroll sync +4. **Mouse hit detection** - Click accuracy with scroll + +See `td-1a735359` for the test task. + +## Common Pitfalls + +1. **Forgetting updateContext()** - Call after closing modal to restore app context +2. **Stale hover index** - Clear `projectSwitcherHover` when filter changes +3. **Cursor out of bounds** - Always clamp after filtering +4. **j/k vs textinput** - Navigation keys must be handled before forwarding to textinput +5. **Mouse Y calculation** - Account for scroll indicators and filter count line + +## File Locations + +| File | Contents | +|------|----------| +| `internal/app/model.go` | State, init, reset, filter, switch | +| `internal/app/update.go` | Keyboard and mouse handlers | +| `internal/app/view.go` | Rendering | +| `internal/config/types.go` | ProjectConfig struct | +| `internal/config/loader.go` | Config loading with path validation | From f81244ab5b3282647364f7b15c4533b4dafb32cd Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Tue, 20 Jan 2026 08:31:03 -0800 Subject: [PATCH 3/4] docs: Update changelog for v0.30.0 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e033d8..f99933f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to sidecar are documented here. +## [v0.30.0] - 2026-01-20 + +### Features +- **Project Switcher**: Type-to-filter support - type to filter projects by name/path in real-time, shows match count, Esc clears filter or closes modal +- **Project Switcher**: j/k keyboard navigation now works correctly (previously went to text input) + +### Bug Fixes +- Fixed project switcher Esc handler missing context update +- Fixed project switcher hover state not clearing on filter change + +### Documentation +- Added project switcher developer guide (`docs/guides/project-switcher-dev-guide.md`) + ## [v0.29.0] - 2026-01-19 ### Features From 0818b3b007e90366b6661ab57d26ca7b113a7fcf Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Tue, 20 Jan 2026 09:10:00 -0800 Subject: [PATCH 4/4] Marketing site changes --- website/src/css/custom.css | 36 +++++++++++++++++++++++++++ website/src/pages/index.js | 51 +++++++++++++++++++++++++++++--------- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/website/src/css/custom.css b/website/src/css/custom.css index a88a7322..85b6e228 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -494,6 +494,14 @@ a:hover { padding: 26px 0 46px; } +.sc-gridHint { + font-size: 12px; + color: rgba(232, 232, 227, 0.45); + margin: 0 0 12px; + text-align: center; + font-family: var(--ifm-font-family-monospace); +} + .sc-gridInner { display: grid; grid-template-columns: repeat(12, 1fr); @@ -1070,6 +1078,34 @@ a:hover { max-width: 60ch; } +/* ---------- Bottom CTA ---------- */ + +.sc-bottomCta { + padding: 64px 0; + text-align: center; + border-top: 1px solid var(--sc-border); + background: linear-gradient(180deg, rgba(15, 17, 20, 0.3), rgba(23, 26, 31, 0.2)); +} + +.sc-bottomCtaTitle { + font-size: 24px; + margin: 0 0 24px; + letter-spacing: -0.3px; +} + +.sc-bottomInstall { + max-width: 600px; + margin: 0 auto 24px; + text-align: left; +} + +.sc-bottomCtaLinks { + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + /* ---------- Workflow Section ---------- */ .sc-workflow { diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 39e75567..60dc74aa 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -719,7 +719,7 @@ export default function Home() { return (
@@ -731,9 +731,8 @@ export default function Home() {

- AI agents write your code. td lets you plan tasks, review diffs, stage commits, - and manage git worktrees without leaving your terminal. The entire development loop - happens here while agents write the code. + AI agents write your code. Sidecar keeps you in the terminal for everything else: + planning tasks with td, reviewing diffs, staging commits, managing worktrees. The whole development loop, one interface.

@@ -779,6 +778,7 @@ export default function Home() { {/* Feature Cards */}
+

Click a card to see it in action above

{/* TD Hero Card - double wide */} handleCardClick('git')} > - Split-pane diffs, commit context, and a fast loop for staging/review--without bouncing to an IDE. + Split-pane diffs, commit context, fast staging—all without bouncing to an IDE. handleCardClick('conversations')} > - Chronological view across Claude, Cursor, Gemini, and all adapters. See every session in one place, - search across agents, and pick up exactly where any agent left off. + All your agents in one timeline—Claude, Cursor, Gemini, and more. Search across sessions, pick up where any agent left off. handleCardClick('worktrees')} > - Create worktrees, pass tasks from td, or kick off with configured prompts--all without typing git commands. - Everything is automatic: create, switch, merge, delete. + Create worktrees, pass tasks from td, kick off with configured prompts—no git commands needed. Everything is automatic.
@@ -845,8 +843,8 @@ export default function Home() { {/* Component Showcase Sections */}
-

Component Deep Dive

-

Each plugin is designed for the AI-assisted development workflow

+

The Plugins

+

Each one built for AI-assisted development

@@ -1017,7 +1015,7 @@ export default function Home() { {/* Supported Agents */}
-

Works with your favorite coding agents

+

Supported Agents

Sidecar reads session data from multiple AI coding tools, giving you a unified view of agent activity

@@ -1100,6 +1098,35 @@ export default function Home() {

+ + {/* Bottom CTA */} +
+
+

Get started in seconds

+
+
+ Quick install + +
+
+ $ + {INSTALL_COMMAND} +
+
+ $ + sidecar +
+
+
+ + Read the docs + + + View on GitHub + +
+
+
);