From d8fe05c2757e03464c660ce09b88f01589a4ecf7 Mon Sep 17 00:00:00 2001 From: alvarorestrepo Date: Wed, 18 Mar 2026 17:46:22 -0500 Subject: [PATCH] feat(sync): selective project export with TUI checkboxes Add --projects flag and interactive TUI selector for choosing which projects to export during sync. - Add --projects=a,b flag for non-interactive multi-project export - Add interactive y/n prompt on TTY before launching project selector - Add ScreenProjectSelector TUI screen with j/k navigation, space toggle, enter confirm and q/esc cancel (channel-based result handoff) - Replace single syncExport call with per-project export loop that accumulates results and prints a summary - Preserve full backward compatibility: --all, --project and CWD default behave exactly as before - Warn to stderr when --all overrides --projects - Add table-driven tests for resolveProjects() and unit tests for handleProjectSelectorKeys() state machine (277 tests passing) --- cmd/engram/main.go | 215 ++++++++++++++++++++++++++++++------ cmd/engram/main_test.go | 99 +++++++++++++++++ internal/tui/model.go | 23 ++++ internal/tui/update.go | 49 ++++++++ internal/tui/update_test.go | 210 +++++++++++++++++++++++++++++++++++ internal/tui/view.go | 41 +++++++ 6 files changed, 601 insertions(+), 36 deletions(-) diff --git a/cmd/engram/main.go b/cmd/engram/main.go index 2bdc329..4676dea 100644 --- a/cmd/engram/main.go +++ b/cmd/engram/main.go @@ -31,6 +31,7 @@ import ( versioncheck "github.com/Gentleman-Programming/engram/internal/version" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/term" mcpserver "github.com/mark3labs/mcp-go/server" ) @@ -589,28 +590,25 @@ func cmdSync(cfg store.Config) { doStatus := false doAll := false project := "" + projectsFlag := "" for i := 2; i < len(os.Args); i++ { - switch os.Args[i] { - case "--import": + switch { + case os.Args[i] == "--import": doImport = true - case "--status": + case os.Args[i] == "--status": doStatus = true - case "--all": + case os.Args[i] == "--all": doAll = true - case "--project": - if i+1 < len(os.Args) { - project = os.Args[i+1] - i++ - } - } - } - - // Default project to current directory name (so sync only exports - // memories for THIS project, not everything in the global DB). - // --all skips project filtering entirely — exports everything. - if !doAll && project == "" { - if cwd, err := os.Getwd(); err == nil { - project = filepath.Base(cwd) + case os.Args[i] == "--project" && i+1 < len(os.Args): + project = os.Args[i+1] + i++ + case strings.HasPrefix(os.Args[i], "--projects="): + // Task 1.1: --projects=val (equals syntax) + projectsFlag = strings.TrimPrefix(os.Args[i], "--projects=") + case os.Args[i] == "--projects" && i+1 < len(os.Args): + // Task 1.1: --projects val (space syntax) + projectsFlag = os.Args[i+1] + i++ } } @@ -660,34 +658,179 @@ func cmdSync(cfg store.Config) { return } + // Task 1.3: TTY detection + isTTY := term.IsTerminal(os.Stdin.Fd()) + + // Task 1.2 + 1.3: Resolve projects via precedence chain + selected, err := resolveProjects(s, doAll, projectsFlag, project, isTTY) + if err != nil { + fatal(err) + } + + // Task 1.3: empty slice = cancelled + if selected != nil && len(selected) == 0 { + fmt.Println("Sync cancelled.") + return + } + // Export: DB → new chunk username := engramsync.GetUsername() - if doAll { + + // Task 1.4: Export loop + // nil = export all (--all path, preserve existing behavior) + if selected == nil { fmt.Println("Exporting ALL memories (all projects)...") + result, err := syncExport(sy, username, "") + if err != nil { + fatal(err) + } + + if result.IsEmpty { + fmt.Println("Nothing new to sync — all memories already exported.") + return + } + + fmt.Printf("Created chunk %s\n", result.ChunkID) + fmt.Printf(" Sessions: %d\n", result.SessionsExported) + fmt.Printf(" Observations: %d\n", result.ObservationsExported) + fmt.Printf(" Prompts: %d\n", result.PromptsExported) + fmt.Println() + fmt.Println("Add to git:") + fmt.Printf(" git add .engram/ && git commit -m \"sync engram memories\"\n") + } else if len(selected) == 1 { + // Single project: preserve existing --project output format for backward compat + proj := selected[0] + fmt.Printf("Exporting memories for project %q...\n", proj) + result, err := syncExport(sy, username, proj) + if err != nil { + fatal(err) + } + + if result.IsEmpty { + fmt.Printf("Nothing new to sync for project %q — all memories already exported.\n", proj) + return + } + + fmt.Printf("Created chunk %s\n", result.ChunkID) + fmt.Printf(" Sessions: %d\n", result.SessionsExported) + fmt.Printf(" Observations: %d\n", result.ObservationsExported) + fmt.Printf(" Prompts: %d\n", result.PromptsExported) + fmt.Println() + fmt.Println("Add to git:") + fmt.Printf(" git add .engram/ && git commit -m \"sync engram memories\"\n") } else { - fmt.Printf("Exporting memories for project %q...\n", project) + // Multi-project loop: iterate selected projects, accumulate results + totalSessions, totalObs, totalPrompts, exported := 0, 0, 0, 0 + for _, proj := range selected { + result, err := syncExport(sy, username, proj) + if err != nil { + fatal(err) + } + if result.IsEmpty { + fmt.Printf(" %s: nothing new to sync\n", proj) + continue + } + fmt.Printf(" %s: chunk %s (%d obs)\n", proj, result.ChunkID, result.ObservationsExported) + totalSessions += result.SessionsExported + totalObs += result.ObservationsExported + totalPrompts += result.PromptsExported + exported++ + } + if exported == 0 { + fmt.Println("Nothing new to sync — all projects already up to date.") + return + } + fmt.Printf("\nExported %d project(s): %d sessions, %d observations, %d prompts\n", exported, totalSessions, totalObs, totalPrompts) + fmt.Println("\nAdd to git:") + fmt.Printf(" git add .engram/ && git commit -m \"sync engram memories\"\n") } - result, err := syncExport(sy, username, project) - if err != nil { - fatal(err) +} + +// resolveProjects determines which projects to export based on flag precedence. +// +// Precedence (highest to lowest): +// 1. doAll → return nil (nil = export all, no filter) +// 2. projectsFlag non-empty → parse comma-separated, trim, deduplicate +// 3. projectFlag non-empty → return []string{projectFlag} +// 4. isTTY → return nil for now (stub: TUI will be wired in Phase 5) +// 5. CWD default → filepath.Base(os.Getwd()) → return []string{cwd} +// +// Return semantics: +// - nil = export all (--all path) +// - []string{...} = specific projects to export +// - []string{} = cancelled (caller should print "Sync cancelled." and return) +func resolveProjects(s *store.Store, doAll bool, projectsFlag, projectFlag string, isTTY bool) ([]string, error) { + // 1. --all flag → export everything + if doAll { + if projectsFlag != "" { + fmt.Fprintln(os.Stderr, "warning: --all overrides --projects") + } + return nil, nil + } + + // 2. --projects flag → parse comma-separated list, trim whitespace, deduplicate + // Falls through to CWD default if all values were blank (e.g. --projects=" ") + if projectsFlag != "" { + parts := strings.Split(projectsFlag, ",") + seen := map[string]bool{} + var result []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" && !seen[p] { + seen[p] = true + result = append(result, p) + } + } + if len(result) > 0 { + return result, nil + } + // All values were blank — fall through to lower precedence } - if result.IsEmpty { - if doAll { - fmt.Println("Nothing new to sync — all memories already exported.") - } else { - fmt.Printf("Nothing new to sync for project %q — all memories already exported.\n", project) + // 3. --project flag (singular) → single project + if projectFlag != "" { + return []string{projectFlag}, nil + } + + // 4. TTY → prompt user; if y, launch TUI project selector + if isTTY { + fmt.Print("Show projects pending sync? [y/n]: ") + var answer string + fmt.Scanln(&answer) + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer == "y" || answer == "yes" { + stats, err := storeStats(s) + if err != nil { + return nil, fmt.Errorf("get projects: %w", err) + } + if len(stats.Projects) == 0 { + fmt.Println("No projects found in memory.") + return []string{}, nil // empty = nothing to sync → "Sync cancelled." + } + resultCh := make(chan []string, 1) + m := tui.NewProjectSelector(s, stats.Projects, resultCh) + p := newTeaProgram(m) + if _, err := runTeaProgram(p); err != nil { + return nil, fmt.Errorf("project selector: %w", err) + } + selected := <-resultCh + // nil means cancelled (q/esc or enter with zero items checked) → + // fall through to CWD default below + if selected != nil { + return selected, nil + } + // fall through to CWD default } - return + // User answered "n" or cancelled → fall through to CWD default } - fmt.Printf("Created chunk %s\n", result.ChunkID) - fmt.Printf(" Sessions: %d\n", result.SessionsExported) - fmt.Printf(" Observations: %d\n", result.ObservationsExported) - fmt.Printf(" Prompts: %d\n", result.PromptsExported) - fmt.Println() - fmt.Println("Add to git:") - fmt.Printf(" git add .engram/ && git commit -m \"sync engram memories\"\n") + // 5. CWD default → use current directory name as project filter + if cwd, err := os.Getwd(); err == nil { + return []string{filepath.Base(cwd)}, nil + } + + // If we can't determine CWD, export all as a safe fallback + return nil, nil } func cmdSetup() { diff --git a/cmd/engram/main_test.go b/cmd/engram/main_test.go index 4e2d6ac..830cedd 100644 --- a/cmd/engram/main_test.go +++ b/cmd/engram/main_test.go @@ -579,3 +579,102 @@ func TestCmdSearchLocalMode(t *testing.T) { t.Fatalf("expected local search results, got: %q", stdout) } } + +// TestResolveProjects covers the full precedence chain of resolveProjects(). +// The isTTY=true path (interactive TUI) is not unit-tested here because it +// requires a real terminal and interactive input — that path is covered by +// manual/integration testing only. +func TestResolveProjects(t *testing.T) { + cfg := testConfig(t) + s, err := store.New(cfg) + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + tests := []struct { + name string + doAll bool + projectsFlag string + projectFlag string + isTTY bool + cwdName string // if non-empty, chdir to a temp dir with this base name + wantNil bool // true = expect nil return + wantProjects []string + }{ + { + name: "doAll=true returns nil regardless of flags", + doAll: true, + wantNil: true, + }, + { + name: "doAll=true wins over --projects flag", + doAll: true, + projectsFlag: "engram,dots", + wantNil: true, + }, + { + name: "projectsFlag comma-separated returns trimmed deduplicated list", + projectsFlag: "engram,dots", + wantProjects: []string{"engram", "dots"}, + }, + { + name: "projectsFlag with extra whitespace is trimmed", + projectsFlag: " engram , dots ", + wantProjects: []string{"engram", "dots"}, + }, + { + name: "projectsFlag with duplicates deduplicates", + projectsFlag: "engram,engram,dots", + wantProjects: []string{"engram", "dots"}, + }, + { + name: "projectFlag (singular) returns single-item slice", + projectFlag: "myproject", + wantProjects: []string{"myproject"}, + }, + { + name: "non-TTY with no flags returns CWD-based default", + isTTY: false, + cwdName: "my-repo", + wantProjects: []string{"my-repo"}, + }, + { + name: "projectsFlag takes precedence over projectFlag", + projectsFlag: "alpha,beta", + projectFlag: "gamma", + wantProjects: []string{"alpha", "beta"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.cwdName != "" { + dir := filepath.Join(t.TempDir(), tc.cwdName) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + withCwd(t, dir) + } + + got, err := resolveProjects(s, tc.doAll, tc.projectsFlag, tc.projectFlag, tc.isTTY) + if err != nil { + t.Fatalf("resolveProjects returned error: %v", err) + } + if tc.wantNil { + if got != nil { + t.Fatalf("expected nil, got %v", got) + } + return + } + if len(got) != len(tc.wantProjects) { + t.Fatalf("got %v, want %v", got, tc.wantProjects) + } + for i, want := range tc.wantProjects { + if got[i] != want { + t.Fatalf("got[%d] = %q, want %q", i, got[i], want) + } + } + }) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 7532f29..ac281ff 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -34,6 +34,7 @@ const ( ScreenSessions ScreenSessionDetail ScreenSetup + ScreenProjectSelector ) // ─── Custom Messages ───────────────────────────────────────────────────────── @@ -136,6 +137,13 @@ type Model struct { SetupAllowlistApplied bool // true = allowlist was added successfully SetupAllowlistError string // error message if allowlist injection failed SetupSpinner spinner.Model + + // Project selector screen + ProjectSelectorItems []string + ProjectSelectorChecked []bool + ProjectSelectorCursor int + ProjectSelectorCounts []int // pending count per project (0 = unknown/not populated) + projectSelectorResultCh chan<- []string // unexported, write-only channel } // New creates a new TUI model connected to the given store. @@ -158,6 +166,21 @@ func New(s *store.Store, version string) Model { } } +// NewProjectSelector creates a TUI model pre-configured for the project +// selection screen. items is the list of project names to display. +// resultCh receives the selected project names when the user confirms, +// or nil if the user cancels (q/esc/enter with nothing checked). +func NewProjectSelector(s *store.Store, items []string, resultCh chan<- []string) Model { + m := New(s, "") + m.Screen = ScreenProjectSelector + m.ProjectSelectorItems = items + m.ProjectSelectorChecked = make([]bool, len(items)) + m.ProjectSelectorCounts = make([]int, len(items)) // zeros — unknown count; extension point for future population + m.ProjectSelectorCursor = 0 + m.projectSelectorResultCh = resultCh + return m +} + // Init loads initial data (stats for the dashboard). func (m Model) Init() tea.Cmd { return tea.Batch( diff --git a/internal/tui/update.go b/internal/tui/update.go index 03a5fee..ccfd6cf 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -154,6 +154,8 @@ func (m Model) handleKeyPress(key string) (tea.Model, tea.Cmd) { return m.handleSessionDetailKeys(key) case ScreenSetup: return m.handleSetupKeys(key) + case ScreenProjectSelector: + return m.handleProjectSelectorKeys(key) } return m, nil } @@ -560,6 +562,53 @@ func (m Model) handleSetupKeys(key string) (tea.Model, tea.Cmd) { return m, nil } +// ─── Project Selector ───────────────────────────────────────────────────────── + +func (m Model) handleProjectSelectorKeys(key string) (tea.Model, tea.Cmd) { + switch key { + case "j", "down": + if m.ProjectSelectorCursor < len(m.ProjectSelectorItems)-1 { + m.ProjectSelectorCursor++ + } + case "k", "up": + if m.ProjectSelectorCursor > 0 { + m.ProjectSelectorCursor-- + } + case " ": + if len(m.ProjectSelectorChecked) > 0 { + m.ProjectSelectorChecked[m.ProjectSelectorCursor] = !m.ProjectSelectorChecked[m.ProjectSelectorCursor] + } + case "enter": + selected := collectSelected(m) + if m.projectSelectorResultCh != nil { + m.projectSelectorResultCh <- selected + } + return m, tea.Quit + case "q", "esc": + if m.projectSelectorResultCh != nil { + m.projectSelectorResultCh <- nil + } + return m, tea.Quit + } + return m, nil +} + +// collectSelected returns the names of checked projects, or nil if none are checked. +// +// nil return means "cancelled" — the caller (cmdSync) treats this as +// "fall through to CWD default", NOT as "export nothing". +// An empty non-nil slice would mean "export nothing" but we never produce that. +// Entering with zero items checked is intentionally treated the same as q/esc. +func collectSelected(m Model) []string { + var selected []string + for i, checked := range m.ProjectSelectorChecked { + if checked { + selected = append(selected, m.ProjectSelectorItems[i]) + } + } + return selected // nil if nothing checked — caller treats nil as cancel → CWD default +} + // ─── Helpers ───────────────────────────────────────────────────────────────── // refreshScreen returns the appropriate data-loading Cmd for a given screen. diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 3c90e56..df0d8d2 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -830,6 +830,216 @@ func TestAdditionalKeyAliasAndBoundaryBranches(t *testing.T) { } } +// ─── Project Selector Tests ──────────────────────────────────────────────────── + +func TestHandleProjectSelectorKeysCursorNavigation(t *testing.T) { + items := []string{"alpha", "beta", "gamma"} + resultCh := make(chan []string, 1) + m := NewProjectSelector(nil, items, resultCh) + + // j moves cursor down (not past end) + t.Run("j moves cursor down", func(t *testing.T) { + m.ProjectSelectorCursor = 0 + updatedModel, cmd := m.handleProjectSelectorKeys("j") + updated := updatedModel.(Model) + if updated.ProjectSelectorCursor != 1 { + t.Fatalf("cursor = %d, want 1", updated.ProjectSelectorCursor) + } + if cmd != nil { + t.Fatal("j should not return command") + } + }) + + t.Run("j does not go past last item", func(t *testing.T) { + m.ProjectSelectorCursor = len(items) - 1 + updatedModel, _ := m.handleProjectSelectorKeys("j") + updated := updatedModel.(Model) + if updated.ProjectSelectorCursor != len(items)-1 { + t.Fatalf("cursor = %d, want %d (clamped at bottom)", updated.ProjectSelectorCursor, len(items)-1) + } + }) + + t.Run("k moves cursor up", func(t *testing.T) { + m.ProjectSelectorCursor = 2 + updatedModel, cmd := m.handleProjectSelectorKeys("k") + updated := updatedModel.(Model) + if updated.ProjectSelectorCursor != 1 { + t.Fatalf("cursor = %d, want 1", updated.ProjectSelectorCursor) + } + if cmd != nil { + t.Fatal("k should not return command") + } + }) + + t.Run("k does not go past 0", func(t *testing.T) { + m.ProjectSelectorCursor = 0 + updatedModel, _ := m.handleProjectSelectorKeys("k") + updated := updatedModel.(Model) + if updated.ProjectSelectorCursor != 0 { + t.Fatalf("cursor = %d, want 0 (clamped at top)", updated.ProjectSelectorCursor) + } + }) + + t.Run("down arrow moves cursor down", func(t *testing.T) { + m.ProjectSelectorCursor = 0 + updatedModel, _ := m.handleProjectSelectorKeys("down") + updated := updatedModel.(Model) + if updated.ProjectSelectorCursor != 1 { + t.Fatalf("cursor = %d, want 1", updated.ProjectSelectorCursor) + } + }) + + t.Run("up arrow moves cursor up", func(t *testing.T) { + m.ProjectSelectorCursor = 2 + updatedModel, _ := m.handleProjectSelectorKeys("up") + updated := updatedModel.(Model) + if updated.ProjectSelectorCursor != 1 { + t.Fatalf("cursor = %d, want 1", updated.ProjectSelectorCursor) + } + }) +} + +func TestHandleProjectSelectorKeysSpaceToggle(t *testing.T) { + items := []string{"alpha", "beta", "gamma"} + resultCh := make(chan []string, 1) + m := NewProjectSelector(nil, items, resultCh) + + t.Run("space toggles checked state on at cursor", func(t *testing.T) { + m.ProjectSelectorCursor = 1 + updatedModel, cmd := m.handleProjectSelectorKeys(" ") + updated := updatedModel.(Model) + if !updated.ProjectSelectorChecked[1] { + t.Fatal("space should set checked to true") + } + if cmd != nil { + t.Fatal("space should not return command") + } + }) + + t.Run("space toggles checked state off again", func(t *testing.T) { + m.ProjectSelectorCursor = 1 + m.ProjectSelectorChecked[1] = true + updatedModel, _ := m.handleProjectSelectorKeys(" ") + updated := updatedModel.(Model) + if updated.ProjectSelectorChecked[1] { + t.Fatal("second space should toggle back to false") + } + }) + + t.Run("space does not panic with empty items", func(t *testing.T) { + emptyResultCh := make(chan []string, 1) + emptyModel := NewProjectSelector(nil, []string{}, emptyResultCh) + // Should not panic — guard in handler checks len > 0 + emptyModel.handleProjectSelectorKeys(" ") + }) +} + +func TestHandleProjectSelectorKeysEnterWithSelection(t *testing.T) { + items := []string{"alpha", "beta", "gamma"} + resultCh := make(chan []string, 1) + m := NewProjectSelector(nil, items, resultCh) + + // Check alpha and gamma + m.ProjectSelectorChecked[0] = true + m.ProjectSelectorChecked[2] = true + + updatedModel, cmd := m.handleProjectSelectorKeys("enter") + _ = updatedModel + + // cmd should be tea.Quit + if cmd == nil { + t.Fatal("enter should return quit command") + } + + // Channel should have received the selected projects + selected, ok := <-resultCh + if !ok { + t.Fatal("channel should not be closed") + } + if len(selected) != 2 { + t.Fatalf("selected = %v, want [alpha, gamma]", selected) + } + if selected[0] != "alpha" || selected[1] != "gamma" { + t.Fatalf("selected = %v, want [alpha, gamma]", selected) + } +} + +func TestHandleProjectSelectorKeysEnterNothingChecked(t *testing.T) { + items := []string{"alpha", "beta", "gamma"} + resultCh := make(chan []string, 1) + m := NewProjectSelector(nil, items, resultCh) + // No items checked — all ProjectSelectorChecked are false + + updatedModel, cmd := m.handleProjectSelectorKeys("enter") + _ = updatedModel + + if cmd == nil { + t.Fatal("enter should return quit command even with nothing checked") + } + + // Channel should have received nil (cancel → CWD fallback, not empty export) + selected, ok := <-resultCh + if !ok { + t.Fatal("channel should not be closed") + } + if selected != nil { + t.Fatalf("enter with nothing checked should send nil to channel, got %v", selected) + } +} + +func TestHandleProjectSelectorKeysQAndEsc(t *testing.T) { + items := []string{"alpha", "beta"} + + t.Run("q sends nil and quits", func(t *testing.T) { + resultCh := make(chan []string, 1) + m := NewProjectSelector(nil, items, resultCh) + + _, cmd := m.handleProjectSelectorKeys("q") + if cmd == nil { + t.Fatal("q should return quit command") + } + selected, ok := <-resultCh + if !ok { + t.Fatal("channel should not be closed") + } + if selected != nil { + t.Fatalf("q should send nil to channel, got %v", selected) + } + }) + + t.Run("esc sends nil and quits", func(t *testing.T) { + resultCh := make(chan []string, 1) + m := NewProjectSelector(nil, items, resultCh) + + _, cmd := m.handleProjectSelectorKeys("esc") + if cmd == nil { + t.Fatal("esc should return quit command") + } + selected, ok := <-resultCh + if !ok { + t.Fatal("channel should not be closed") + } + if selected != nil { + t.Fatalf("esc should send nil to channel, got %v", selected) + } + }) +} + +func TestHandleProjectSelectorKeysRouterIncludesScreen(t *testing.T) { + // Verify that ScreenProjectSelector is in the key-press router and clears error. + items := []string{"alpha"} + resultCh := make(chan []string, 1) + m := NewProjectSelector(nil, items, resultCh) + m.ErrorMsg = "old error" + + // An unknown key should go through the router without panic and clear error + updatedModel, _ := m.handleKeyPress("x") + updated := updatedModel.(Model) + if updated.ErrorMsg != "" { + t.Fatalf("ScreenProjectSelector should clear error on key press, got %q", updated.ErrorMsg) + } +} + func TestNavigationScrollAndSelectionBranches(t *testing.T) { fx := newTestFixture(t) diff --git a/internal/tui/view.go b/internal/tui/view.go index 8b76462..335ef2c 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -78,6 +78,8 @@ func (m Model) View() string { content = m.viewSessionDetail() case ScreenSetup: content = m.viewSetup() + case ScreenProjectSelector: + content = viewProjectSelector(m) default: content = "Unknown screen" } @@ -665,6 +667,45 @@ func (m Model) viewSetup() string { return b.String() } +// ─── Project Selector ──────────────────────────────────────────────────────── + +func viewProjectSelector(m Model) string { + var b strings.Builder + + b.WriteString(headerStyle.Render(" Select Projects to Sync")) + b.WriteString("\n") + b.WriteString(helpStyle.Render(" space toggle • enter confirm • q cancel")) + b.WriteString("\n\n") + + if len(m.ProjectSelectorItems) == 0 { + b.WriteString(noResultsStyle.Render("No projects found.")) + return b.String() + } + + for i, item := range m.ProjectSelectorItems { + checkbox := "[ ]" + if m.ProjectSelectorChecked[i] { + checkbox = lipgloss.NewStyle().Foreground(colorGreen).Bold(true).Render("[x]") + } + + label := item + if i < len(m.ProjectSelectorCounts) && m.ProjectSelectorCounts[i] > 0 { + label = fmt.Sprintf("%s (%d pending)", item, m.ProjectSelectorCounts[i]) + } + + if m.ProjectSelectorCursor == i { + line := fmt.Sprintf("▸ %s %s", checkbox, label) + b.WriteString(menuSelectedStyle.Render(line)) + } else { + line := fmt.Sprintf(" %s %s", checkbox, label) + b.WriteString(menuItemStyle.Render(line)) + } + b.WriteString("\n") + } + + return b.String() +} + // ─── Shared Renderers ──────────────────────────────────────────────────────── func (m Model) renderObservationListItem(index int, id int64, obsType, title, content, createdAt string, project *string) string {