From 5521ff67bee81069e86adb34d54b00dd6928d978 Mon Sep 17 00:00:00 2001 From: itsdevcoffee Date: Wed, 4 Feb 2026 03:03:28 -0500 Subject: [PATCH 1/7] feat: improve TUI UX with conservative polish (Option 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements foundational UX improvements to address user confusion between plugin search and marketplace browser interfaces. Key Changes: - Unified terminology: "filter" → "view", "sort mode" → "order" - Made @marketplace filtering discoverable with placeholder hints - Added context breadcrumbs when returning from marketplace browser - Enhanced help view with context-aware sections and annotations - Fixed critical bugs: pointer stability, bounds checking, string parsing - Simplified code with helper functions and reduced duplication UX Improvements: - Tab key now shows "next view" (plugin list) vs "change order" (marketplace) - Search placeholder: "Search plugins (or @marketplace to filter)..." - Status bar displays active marketplace filter: "@marketplace-name (N results)" - Help view now groups shortcuts by context (plugin actions, marketplace actions, etc.) - Breadcrumb appears briefly when returning to plugin list: "← from Marketplace Browser" Bug Fixes: - Fixed stale pointer reference when selecting marketplace (copy value instead of pointer to slice) - Added final bounds check to prevent negative scroll offset - Replaced broken string parsing with strings.SplitN for plugin@marketplace format - Improved viewport height bounds checking for very small terminals - Added URL validation before exec.Command calls (GitHub prefix check) Code Quality: - Extracted helper functions: formatPluginCount, formatGitHubStats, openURL, openPath - Consolidated flash message clearing with unified clearFlashAfter function - Extracted viewport initialization into dedicated functions - Simplified status bar rendering with focused helper functions - All tests passing, linter clean, build successful Related: docs/research/2026-02-04-tui-ux-improvement-options.md Tasks completed: #1-4 (Option 1 - Conservative Polish) --- .../2026-02-04-tui-ux-improvement-options.md | 472 ++++++++++++++++++ internal/ui/help_view.go | 126 ++--- internal/ui/marketplace_view.go | 140 +++--- internal/ui/model.go | 71 ++- internal/ui/update.go | 335 ++++++------- internal/ui/view.go | 53 +- 6 files changed, 845 insertions(+), 352 deletions(-) create mode 100644 docs/research/2026-02-04-tui-ux-improvement-options.md diff --git a/docs/research/2026-02-04-tui-ux-improvement-options.md b/docs/research/2026-02-04-tui-ux-improvement-options.md new file mode 100644 index 0000000..517b6b1 --- /dev/null +++ b/docs/research/2026-02-04-tui-ux-improvement-options.md @@ -0,0 +1,472 @@ +# TUI UX Improvement Options + +**Date:** 2026-02-04 +**Status:** Analysis & Recommendations +**Context:** User feedback that plugin search vs marketplace browser feels clunky and inconsistent + +## Executive Summary + +The current TUI has **5 views** (plugin list, plugin detail, marketplace list, marketplace detail, help) with good separation of concerns but suffers from: + +1. **Inconsistent keyboard patterns** - Tab key means different things in different contexts +2. **Hidden features** - `@marketplace` filter syntax not discoverable +3. **Multi-step workflows** - Common tasks require 4-7 navigation steps +4. **Context loss** - Active filters/search state unclear when switching views +5. **Asymmetric navigation** - Some cross-view jumps feel unintuitive + +## Current Issues Deep Dive + +### Issue 1: Inconsistent Tab Behavior + +**Current State:** +- Plugin List: `Tab` cycles filters (All → Discover → Ready → Installed) +- Marketplace List: `Tab` cycles sort modes (Plugins → Stars → Name → Updated) + +**Problem:** Same key performs conceptually different actions. Users build muscle memory in one view that doesn't transfer. + +**User Impact:** Medium - Can be confusing but learnable + +--- + +### Issue 2: Hidden Marketplace Filter Syntax + +**Current State:** +- Plugin list supports `@marketplace-name` to filter by marketplace +- Only documented in help view, no visual hints + +**Problem:** Users unlikely to discover this powerful feature without reading help. + +**User Impact:** High - Many users probably never discover this + +--- + +### Issue 3: Multi-Step Workflows + +**Example 1: Install Discoverable Plugin** +1. View plugin details +2. Copy marketplace command (`c`) +3. Exit TUI +4. Install marketplace +5. Re-enter TUI +6. Search plugin again +7. Copy plugin command (`y`) + +**7 steps total** + +**Example 2: Browse Marketplace Plugins** +1. Open marketplace browser (`Shift+M`) +2. Navigate to marketplace +3. Press `Enter` for details +4. Press `f` to filter plugins + +**4 steps total** + +**Problem:** Common tasks require too many navigation steps and context switches. + +**User Impact:** High - Primary workflows feel tedious + +--- + +### Issue 4: Context Loss Between Views + +**Current State:** +- When switching to marketplace browser, active filter (All/Discover/Ready/Installed) is preserved but invisible +- Search query remains active but hidden +- Returning to plugin list, users forget what filter/search was active + +**Problem:** Users lose orientation when switching between views. + +**User Impact:** Medium - Causes occasional confusion + +--- + +### Issue 5: Asymmetric Navigation + +**Current Pattern:** +- Most views: `Enter` forward, `Esc` back (symmetric) +- Marketplace detail: `f` goes to plugin list with filter (asymmetric cross-view jump) + +**Problem:** The `f` key breaks expected navigation patterns - it's not a pure "back", it's a contextual action that changes views and state. + +**User Impact:** Low - Works but feels inconsistent + +--- + +### Issue 6: Display Mode Only in Plugin List + +**Current State:** +- Plugin list has Card/Slim toggle (`Shift+V`) +- Marketplace list has only one display mode + +**Problem:** Users might try `Shift+V` in marketplace view, expecting similar behavior. + +**User Impact:** Low - Minor inconsistency + +--- + +## Improvement Options + +I'm presenting **4 options** ranging from conservative fixes to ambitious redesign. Each option builds on the previous. + +--- + +## Option 1: Conservative Polish (Low Risk) + +**Philosophy:** Fix obvious inconsistencies without changing navigation model + +### Changes + +#### 1.1 Unify Tab Terminology +- Rename "filters" → "views" in plugin list +- Rename "sort modes" → "order" in marketplace list +- Update status bar to clearly show: `[Tab: Next View]` vs `[Tab: Next Order]` + +#### 1.2 Make `@marketplace` Filter Discoverable +- Add hint to search placeholder: `Search plugins (or @marketplace to filter)...` +- Add autocomplete: When user types `@`, show list of marketplace names +- Show active marketplace filter in status bar: `@claude-code-plugins (12 results)` + +#### 1.3 Add Context Breadcrumbs +- Show active filter in status bar when returning from marketplace: `Installed | 25 plugins` +- Show previous view context when switching: `← from Marketplace Browser` + +#### 1.4 Contextual Help View +- Filter help shortcuts based on current view +- Mark unavailable shortcuts as grayed out with context note + +### Pros +- Low implementation risk +- Fixes most annoying UX issues +- Doesn't break existing muscle memory +- Can be done incrementally + +### Cons +- Doesn't address multi-step workflow issues +- Navigation model still feels a bit clunky +- Tab behavior still conceptually different between views + +### Implementation Complexity +**Effort:** 2-3 days +**Files Changed:** 5-7 files +**Risk:** Low + +--- + +## Option 2: Unified Filter/Sort Bar (Medium Risk) + +**Philosophy:** Make Tab behavior consistent by unifying filters and sort into one model + +### Changes + +All of **Option 1**, plus: + +#### 2.1 Unified Filter Bar Model +Replace separate "filters" and "sort modes" with unified "facets": + +**Plugin List Facets:** +``` +All | Discover | Ready | Installed | ↑Name | ↑Updated | ↑Stars +``` + +**Marketplace List Facets:** +``` +All | Installed | Cached | ↑Plugins | ↑Stars | ↑Name | ↑Updated +``` + +- First N facets are filters (mutually exclusive) +- Remaining facets are sort orders (cycle through with subsequent Tab) +- Visual separator: `|` between filters and sorts + +#### 2.2 Consistent Tab Behavior +- `Tab` always cycles to next facet +- Filters are mutually exclusive (radio button model) +- Sort facets show direction arrows (↑↓) + +### Pros +- Tab key now means the same thing everywhere +- More powerful: Can filter AND sort simultaneously +- Clearer mental model for users +- Marketplace list gets filtering capability + +### Cons +- More complex status bar rendering +- Wider status bar required (might wrap on small terminals) +- Requires rethinking current filter/sort state management +- Could feel "busier" visually + +### Implementation Complexity +**Effort:** 4-6 days +**Files Changed:** 8-12 files +**Risk:** Medium - requires refactoring filter/sort logic + +--- + +## Option 3: Quick Action Menu (Medium-High Risk) + +**Philosophy:** Reduce multi-step workflows with contextual quick actions + +### Changes + +All of **Option 1**, plus: + +#### 3.1 Quick Action Menu (Press `Space`) + +**From Plugin List:** +``` +┌─ Quick Actions ────────────────┐ +│ [m] Browse Marketplaces │ +│ [f] Filter by Marketplace... │ ← Opens marketplace picker +│ [s] Sort by... │ ← Opens sort picker +│ [v] Toggle View Mode │ +│ [u] Refresh Cache │ +└────────────────────────────────┘ +``` + +**From Plugin Detail (Discoverable):** +``` +┌─ Quick Actions ────────────────┐ +│ [i] Copy 2-Step Install │ ← Copies both commands +│ [m] Copy Marketplace Install │ +│ [p] Copy Plugin Install │ +│ [g] Open on GitHub │ +│ [l] Copy GitHub Link │ +└────────────────────────────────┘ +``` + +**From Marketplace List:** +``` +┌─ Quick Actions ────────────────┐ +│ [Enter] View Details │ +│ [f] Show Plugins from This │ ← Direct filter, no detail view +│ [i] Copy Install Command │ +│ [g] Open on GitHub │ +└────────────────────────────────┘ +``` + +#### 3.2 Marketplace Picker (Triggered by `f` in quick menu or `Shift+F`) +``` +┌─ Filter by Marketplace ────────┐ +│ > claude-code-plugins-plus │ ← Fuzzy search enabled +│ claude-code-marketplace │ +│ anthropic-agent-skills │ +│ ... │ +└────────────────────────────────┘ +``` + +Select marketplace → immediately returns to plugin list with `@marketplace` filter applied. + +**Reduces workflow from 4 steps to 2 steps.** + +#### 3.3 Copy 2-Step Install +For discoverable plugins, `i` key copies both commands to clipboard: +``` +# Step 1: Install marketplace +/plugin marketplace add feed-mob/claude-code-marketplace + +# Step 2: Install plugin +/plugin install csv-parser@feedmob-claude-plugins +``` + +**Reduces workflow from 7 steps to 2 steps** (copy + paste). + +### Pros +- Dramatically reduces multi-step workflows +- Quick actions are contextual (only show relevant options) +- Marketplace picker makes filtering discoverable +- Power users can still use direct key shortcuts +- 2-step install copy is huge UX win for discoverable plugins + +### Cons +- Adds new concept (quick action menu) to learn +- `Space` key conflicts if used for page down (currently `Ctrl+d`) +- More UI complexity to maintain +- Marketplace picker is essentially a third list view + +### Implementation Complexity +**Effort:** 6-8 days +**Files Changed:** 12-18 files +**Risk:** Medium-High - new UI component (menu overlay) + picker view + +--- + +## Option 4: Unified Dual-Pane View (High Risk) + +**Philosophy:** Rethink the navigation model entirely - side-by-side instead of view switching + +### Changes + +All of **Option 1**, plus: + +#### 4.1 Dual-Pane Layout + +**Wide Terminals (>140 cols):** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Plum - Plugin Manager │ +├────────────── Plugins ─────────┬──────── Marketplaces ──────────┤ +│ > csv-parser │ claude-code-plugins-plus │ +│ claude-commit │ claude-code-marketplace │ +│ ai-writer │ > anthropic-agent-skills │ +│ ... │ wshobson-agents │ +│ │ ... │ +│ │ │ +│ @anthropic-agent-skills (2) │ ● Installed | 2/2 plugins │ +│ All | Card View | ↑Name │ ★ 62,390 | Updated 2h ago │ +└────────────────────────────────┴────────────────────────────────┘ +``` + +**Narrow Terminals (<140 cols):** +Falls back to current view-switching model. + +#### 4.2 Navigation +- `Tab` switches focus between panes (plugin list ↔ marketplace list) +- `Enter` on plugin → plugin detail (full screen overlay) +- `Enter` on marketplace → marketplace detail (full screen overlay) +- `f` on marketplace → filter plugin pane by selected marketplace +- Active pane has highlighted border + +#### 4.3 Synchronized Filtering +- Selecting marketplace in right pane filters plugins in left pane +- Filtering plugins in left pane highlights relevant marketplace in right pane +- Real-time visual connection between panes + +### Pros +- See both plugins AND marketplaces simultaneously +- No more context switching between views +- Filter relationships are visually obvious +- Modern, professional feel (like Vim split windows or VS Code panels) +- Tab key has one clear meaning: switch pane focus + +### Cons +- **Major redesign** - complete rewrite of view layer +- Only works well on wide terminals (>140 cols) +- Requires fallback to single-pane on small terminals +- More complex state management (two active cursors) +- Animation system needs refactoring for pane transitions +- Accessibility concerns (more cognitive load) + +### Implementation Complexity +**Effort:** 12-15 days +**Files Changed:** 20+ files (nearly all of internal/ui/) +**Risk:** High - fundamental architecture change + +--- + +## Comparison Matrix + +| Criteria | Option 1 | Option 2 | Option 3 | Option 4 | +|----------|----------|----------|----------|----------| +| **Fixes Tab inconsistency** | Partial | ✅ Full | Partial | ✅ Full | +| **Makes @filter discoverable** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | +| **Reduces multi-step workflows** | ❌ No | ❌ No | ✅ Yes | ✅ Yes | +| **Fixes context loss** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | +| **Implementation time** | 2-3 days | 4-6 days | 6-8 days | 12-15 days | +| **Risk level** | Low | Medium | Med-High | High | +| **Breaking changes** | None | Minor | Minor | Major | +| **Cognitive load** | Low | Low | Medium | Medium-High | +| **Terminal size requirement** | Any | Any | Any | >140 cols (with fallback) | + +--- + +## Recommendations + +### For Quick Win: **Option 1 (Conservative Polish)** +- Safe, incremental improvements +- Fixes the most annoying issues (hidden @filter, context loss, tab terminology) +- Can ship quickly +- Low risk of regression + +**Recommended if:** You want immediate improvement with minimal risk + +--- + +### For Best Balance: **Option 3 (Quick Action Menu)** +- Addresses all major issues including multi-step workflows +- Introduces one new concept (quick action menu) but familiar from other TUIs +- 2-step install copy is a killer feature for discoverability +- Marketplace picker makes filtering actually discoverable +- Medium risk but high reward + +**Recommended if:** You want to meaningfully improve UX and have ~1 week to invest + +--- + +### For Maximum Impact: **Option 2 + Option 3 (Unified Facets + Quick Actions)** +- Combine unified filter/sort bar (Option 2) with quick action menu (Option 3) +- Tab behavior is consistent everywhere +- Multi-step workflows are fast +- Best of both approaches + +**Recommended if:** You want comprehensive UX overhaul with acceptable risk + +--- + +### For Long-Term Vision: **Option 4 (Dual-Pane)** +- Complete rethink of navigation model +- Modern, professional appearance +- Only worth it if: + - You plan to add more features that benefit from split view + - Most users have wide terminals + - You're willing to maintain fallback mode for narrow terminals + +**Recommended if:** You want to set Plum apart visually and have 2-3 weeks to invest + +--- + +## Implementation Strategy + +### Phased Approach (Recommended) + +**Phase 1 (v0.5.0):** Ship Option 1 (Conservative Polish) +- Low risk, immediate value +- Gets user feedback on terminology changes +- Estimated: 2-3 days + +**Phase 2 (v0.6.0):** Add Option 3 (Quick Action Menu) +- Build on Option 1 improvements +- Focus on reducing multi-step workflows +- Estimated: 6-8 days (including testing) + +**Phase 3 (v0.7.0):** Add Option 2 (Unified Facets) OR Option 4 (Dual-Pane) +- Choose based on Phase 2 user feedback +- Option 2 if users want more power in current model +- Option 4 if users want visual overhaul +- Estimated: 4-6 days (Option 2) or 12-15 days (Option 4) + +### All-At-Once Approach + +**Target Release: v0.5.0** - Ship Option 2 + Option 3 together +- Takes ~10-12 days total +- Comprehensive UX upgrade in one release +- Higher risk but bigger splash + +--- + +## Next Steps + +1. **Decide on approach:** + - Conservative (Option 1) + - Balanced (Option 2 + 3) + - Ambitious (Option 4) + - Phased (1 → 3 → 2 or 4) + +2. **Create detailed design docs:** + - Keyboard shortcut mapping + - State machine transitions + - Visual mockups for new views + +3. **Prototype:** + - Build quick action menu in isolation + - Test unified facet bar rendering + - Validate dual-pane layout on different terminal sizes + +4. **User testing:** + - Share prototype with early users + - Gather feedback on navigation flow + - Validate terminology choices + +Would you like me to: +- Create detailed design docs for a specific option? +- Build a prototype for quick action menu? +- Create visual mockups? +- Start implementation on Option 1? diff --git a/internal/ui/help_view.go b/internal/ui/help_view.go index 4608579..e1d19c2 100644 --- a/internal/ui/help_view.go +++ b/internal/ui/help_view.go @@ -9,41 +9,25 @@ import ( // helpView renders the help view with sticky header/footer func (m Model) helpView() string { - // Wrapper with only left/right margin (no top/bottom) - helpWrapperStyle := lipgloss.NewStyle(). - Padding(0, 2, 0, 2) + helpWrapperStyle := lipgloss.NewStyle().Padding(0, 2, 0, 2) + helpBoxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PlumBright). + Padding(1, 2) - // Generate sticky header header := m.generateHelpHeader() - - // Generate sticky footer footer := m.generateHelpFooter() - // Use viewport for scrollable content if m.helpViewport.Height > 0 { viewportContent := m.helpViewport.View() - - // Add scrollbar (aligned with viewport only) scrollbar := m.renderHelpScrollbar() contentWithScrollbar := lipgloss.JoinHorizontal(lipgloss.Top, viewportContent, scrollbar) - // Stack: header (sticky) + viewport (scrolls) + footer (sticky) - fullContent := lipgloss.JoinVertical(lipgloss.Left, - header, - contentWithScrollbar, - footer, - ) - - // Wrap in box - helpBoxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(PlumBright). - Padding(1, 2) - + fullContent := lipgloss.JoinVertical(lipgloss.Left, header, contentWithScrollbar, footer) return helpWrapperStyle.Render(helpBoxStyle.Render(fullContent)) } - // Fallback: render everything together (no viewport) + // Fallback when viewport not initialized var fullContent strings.Builder fullContent.WriteString(header) fullContent.WriteString("\n") @@ -51,23 +35,16 @@ func (m Model) helpView() string { fullContent.WriteString("\n") fullContent.WriteString(footer) - helpBoxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(PlumBright). - Padding(1, 2) - return helpWrapperStyle.Render(helpBoxStyle.Render(fullContent.String())) } // generateHelpHeader generates the sticky header func (m Model) generateHelpHeader() string { - var b strings.Builder - - installedOnlyStyle := lipgloss.NewStyle().Foreground(Success) - contentWidth := 58 + const contentWidth = 58 title := DetailTitleStyle.Render("🍑 plum Help") + installedOnlyStyle := lipgloss.NewStyle().Foreground(Success) legendText := installedOnlyStyle.Render("🟢") + " = installed only" legendStyle := lipgloss.NewStyle(). Foreground(TextMuted). @@ -77,21 +54,19 @@ func (m Model) generateHelpHeader() string { headerLine := lipgloss.JoinHorizontal(lipgloss.Top, title, legend) + var b strings.Builder b.WriteString(headerLine) b.WriteString("\n") b.WriteString(strings.Repeat("─", contentWidth)) - return b.String() } // generateHelpFooter generates the sticky footer func (m Model) generateHelpFooter() string { var b strings.Builder - b.WriteString(strings.Repeat("─", 58)) b.WriteString("\n") b.WriteString(HelpTextStyle.Render(" Press any key to return (↑↓ to scroll)")) - return b.String() } @@ -122,45 +97,69 @@ func (m Model) generateHelpSections() string { // Views & Browsing section b.WriteString(HelpSectionStyle.Render(" 👁️ Views & Browsing")) b.WriteString("\n") - viewKeys := []struct{ key, desc string }{ - {"Enter", "View details"}, - {"Shift+M", "Marketplace browser"}, - {"?", "Toggle help"}, + viewKeys := []struct{ key, desc, context string }{ + {"Enter", "View details", "(plugin/marketplace list)"}, + {"Shift+M", "Marketplace browser", "(any view)"}, + {"?", "Toggle help", "(any view)"}, } for _, h := range viewKeys { - b.WriteString(fmt.Sprintf(" %s %s\n", KeyStyle.Width(16).Render(h.key), HelpTextStyle.Render(h.desc))) + desc := HelpTextStyle.Render(h.desc) + if h.context != "" { + desc += " " + contextStyle.Render(h.context) + } + b.WriteString(fmt.Sprintf(" %s %s\n", KeyStyle.Width(16).Render(h.key), desc)) } b.WriteString(dividerStyle.Render(" " + strings.Repeat("─", 56))) b.WriteString("\n") // Plugin Actions section - b.WriteString(HelpSectionStyle.Render(" 📦 Plugin Actions ") + contextStyle.Render("(detail view)")) + b.WriteString(HelpSectionStyle.Render(" 📦 Plugin Actions ") + contextStyle.Render("(plugin detail view)")) b.WriteString("\n") pluginKeys := []struct{ key, desc, suffix string }{ {"c", "Copy install command", ""}, + {"y", "Copy plugin install", " (discover only)"}, {"g", "Open on GitHub", ""}, {"o", "Open local directory", " 🟢"}, {"p", "Copy local path", " 🟢"}, {"l", "Copy GitHub link", ""}, - {"f", "Filter by marketplace", ""}, } for _, h := range pluginKeys { desc := HelpTextStyle.Render(h.desc) if h.suffix != "" { - desc += installedOnlyStyle.Render(h.suffix) + if strings.Contains(h.suffix, "🟢") { + desc += installedOnlyStyle.Render(h.suffix) + } else { + desc += contextStyle.Render(h.suffix) + } } b.WriteString(fmt.Sprintf(" %s %s\n", KeyStyle.Width(16).Render(h.key), desc)) } b.WriteString(dividerStyle.Render(" " + strings.Repeat("─", 56))) b.WriteString("\n") + // Marketplace Actions section + b.WriteString(HelpSectionStyle.Render(" 🏪 Marketplace Actions ") + contextStyle.Render("(marketplace detail)")) + b.WriteString("\n") + marketplaceKeys := []struct{ key, desc string }{ + {"c", "Copy marketplace install command"}, + {"f", "Filter plugins by this marketplace"}, + {"g", "Open on GitHub"}, + {"l", "Copy GitHub link"}, + } + for _, h := range marketplaceKeys { + b.WriteString(fmt.Sprintf(" %s %s\n", KeyStyle.Width(16).Render(h.key), HelpTextStyle.Render(h.desc))) + } + b.WriteString(dividerStyle.Render(" " + strings.Repeat("─", 56))) + b.WriteString("\n") + // Display & Filters section - b.WriteString(HelpSectionStyle.Render(" 🎨 Display & Filters")) + b.WriteString(HelpSectionStyle.Render(" 🎨 Display & Views ") + contextStyle.Render("(plugin list)")) b.WriteString("\n") displayKeys := []struct{ key, desc string }{ - {"Tab →", "Next filter"}, - {"Shift+Tab ←", "Previous filter"}, - {"Shift+V", "Toggle view mode"}, + {"Tab →", "Next view (All/Discover/Ready/Installed)"}, + {"Shift+Tab ←", "Previous view"}, + {"Shift+V", "Toggle display mode (card/slim)"}, + {"@marketplace", "Filter by marketplace (in search)"}, } for _, h := range displayKeys { b.WriteString(fmt.Sprintf(" %s %s\n", KeyStyle.Width(16).Render(h.key), HelpTextStyle.Render(h.desc))) @@ -168,6 +167,19 @@ func (m Model) generateHelpSections() string { b.WriteString(dividerStyle.Render(" " + strings.Repeat("─", 56))) b.WriteString("\n") + // Marketplace Sorting section + b.WriteString(HelpSectionStyle.Render(" 🔄 Marketplace Sorting ") + contextStyle.Render("(marketplace list)")) + b.WriteString("\n") + sortKeys := []struct{ key, desc string }{ + {"Tab →", "Next sort order (Plugins/Stars/Name/Updated)"}, + {"Shift+Tab ←", "Previous sort order"}, + } + for _, h := range sortKeys { + b.WriteString(fmt.Sprintf(" %s %s\n", KeyStyle.Width(16).Render(h.key), HelpTextStyle.Render(h.desc))) + } + b.WriteString(dividerStyle.Render(" " + strings.Repeat("─", 56))) + b.WriteString("\n") + // System section b.WriteString(HelpSectionStyle.Render(" ⚙️ System")) b.WriteString("\n") @@ -185,23 +197,14 @@ func (m Model) generateHelpSections() string { // renderHelpScrollbar renders a plum-themed scrollbar for the help viewport func (m Model) renderHelpScrollbar() string { - if m.helpViewport.Height <= 0 { + if m.helpViewport.Height <= 0 || (m.helpViewport.AtTop() && m.helpViewport.AtBottom()) { return "" } - // Check if content is scrollable - if m.helpViewport.AtTop() && m.helpViewport.AtBottom() { - return "" // Content fits, no scrollbar needed - } - - // Get dimensions visibleHeight := m.helpViewport.Height scrollPercent := m.helpViewport.ScrollPercent() - - // Estimate total content height (heuristic) totalHeight := visibleHeight * 2 - // Calculate thumb size (proportional) thumbHeight := (visibleHeight * visibleHeight) / totalHeight if thumbHeight < 1 { thumbHeight = 1 @@ -210,16 +213,13 @@ func (m Model) renderHelpScrollbar() string { thumbHeight = visibleHeight } - // Calculate thumb position trackHeight := visibleHeight - thumbHeight thumbPos := int(float64(trackHeight) * scrollPercent) - // Render scrollbar with plum theme - var scrollbar strings.Builder - - thumbStyle := lipgloss.NewStyle().Foreground(PlumBright) // Orange thumb - trackStyle := lipgloss.NewStyle().Foreground(BorderSubtle) // Brown track + thumbStyle := lipgloss.NewStyle().Foreground(PlumBright) + trackStyle := lipgloss.NewStyle().Foreground(BorderSubtle) + var scrollbar strings.Builder for i := 0; i < visibleHeight; i++ { if i >= thumbPos && i < thumbPos+thumbHeight { scrollbar.WriteString(thumbStyle.Render("█")) diff --git a/internal/ui/marketplace_view.go b/internal/ui/marketplace_view.go index 2bb1329..f72fceb 100644 --- a/internal/ui/marketplace_view.go +++ b/internal/ui/marketplace_view.go @@ -6,6 +6,7 @@ import ( "time" "github.com/charmbracelet/lipgloss" + "github.com/itsdevcoffee/plum/internal/marketplace" ) // marketplaceListView renders the marketplace browser view @@ -45,74 +46,74 @@ func (m Model) marketplaceListView() string { // renderMarketplaceItem renders a single marketplace entry func (m Model) renderMarketplaceItem(item MarketplaceItem, selected bool) string { - // Status indicator - var indicator string - switch item.Status { + indicator := m.marketplaceIndicator(item.Status) + prefix := m.selectionPrefix(selected) + nameStyle := m.nameStyle(selected) + name := nameStyle.Render(item.DisplayName) + + pluginCountStr := formatPluginCount(item.InstalledPluginCount, item.TotalPluginCount) + statsStr := formatGitHubStats(item.GitHubStats, item.StatsLoading, item.StatsError) + + tertiaryStyle := lipgloss.NewStyle().Foreground(TextTertiary) + mutedStyle := lipgloss.NewStyle().Foreground(TextMuted) + + return fmt.Sprintf("%s%s %s %s %s", + prefix, indicator, name, + tertiaryStyle.Render(pluginCountStr), + mutedStyle.Render(statsStr)) +} + +func (m Model) marketplaceIndicator(status MarketplaceStatus) string { + switch status { case MarketplaceInstalled: - indicator = InstalledIndicator.String() + return InstalledIndicator.String() case MarketplaceCached: - indicator = "◆" // Diamond for cached + return "◆" case MarketplaceNew: - indicator = "★" // Star for new + return "★" default: - indicator = AvailableIndicator.String() + return AvailableIndicator.String() } +} - // Selection prefix - var prefix string +func (m Model) selectionPrefix(selected bool) string { if selected { - prefix = HighlightBarFull.String() - } else { - prefix = " " + return HighlightBarFull.String() } + return " " +} - // Name style - var nameStyle lipgloss.Style +func (m Model) nameStyle(selected bool) lipgloss.Style { if selected { - nameStyle = PluginNameSelectedStyle - } else { - nameStyle = PluginNameStyle + return PluginNameSelectedStyle } + return PluginNameStyle +} - name := nameStyle.Render(item.DisplayName) - - // Plugin count - var pluginCountStr string - if item.TotalPluginCount > 0 { - if item.InstalledPluginCount > 0 { - pluginCountStr = fmt.Sprintf("(%d/%d plugins)", - item.InstalledPluginCount, item.TotalPluginCount) - } else { - pluginCountStr = fmt.Sprintf("(%d plugins)", item.TotalPluginCount) +func formatPluginCount(installed, total int) string { + if total > 0 { + if installed > 0 { + return fmt.Sprintf("(%d/%d plugins)", installed, total) } - } else { - pluginCountStr = "(? plugins)" + return fmt.Sprintf("(%d plugins)", total) } + return "(? plugins)" +} - // GitHub stats - var statsStr string - if item.GitHubStats != nil { - stats := item.GitHubStats - starsStr := formatNumber(stats.Stars) - forksStr := formatNumber(stats.Forks) - lastUpdated := formatRelativeTime(stats.LastPushedAt) - statsStr = fmt.Sprintf("⭐ %s 🍴 %s 🕒 %s", - starsStr, forksStr, lastUpdated) - } else if item.StatsLoading { - statsStr = "Loading stats..." - } else if item.StatsError != nil { - statsStr = "Stats unavailable" +func formatGitHubStats(stats *marketplace.GitHubStats, loading bool, err error) string { + if stats != nil { + return fmt.Sprintf("⭐ %s 🍴 %s 🕒 %s", + formatNumber(stats.Stars), + formatNumber(stats.Forks), + formatRelativeTime(stats.LastPushedAt)) } - - // Create styles for text - tertiaryStyle := lipgloss.NewStyle().Foreground(TextTertiary) - mutedStyle := lipgloss.NewStyle().Foreground(TextMuted) - - pluginCount := tertiaryStyle.Render(pluginCountStr) - stats := mutedStyle.Render(statsStr) - - return fmt.Sprintf("%s%s %s %s %s", - prefix, indicator, name, pluginCount, stats) + if loading { + return "Loading stats..." + } + if err != nil { + return "Stats unavailable" + } + return "" } // renderMarketplaceSortTabs renders sort mode tabs @@ -151,7 +152,7 @@ func (m Model) renderMarketplaceSortTabs() string { } } - hint := HelpStyle.Render(" (Tab/← → to change sort)") + hint := HelpStyle.Render(" (Tab/← → to change order)") return b.String() + hint } @@ -301,32 +302,35 @@ func formatRelativeTime(t time.Time) string { } duration := time.Since(t) + hours := duration.Hours() - switch { - case duration < time.Hour: + if hours < 1 { return fmt.Sprintf("%dm ago", int(duration.Minutes())) - case duration < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(duration.Hours())) - case duration < 7*24*time.Hour: - return fmt.Sprintf("%dd ago", int(duration.Hours()/24)) - case duration < 30*24*time.Hour: - return fmt.Sprintf("%dw ago", int(duration.Hours()/24/7)) - case duration < 365*24*time.Hour: - return fmt.Sprintf("%dmo ago", int(duration.Hours()/24/30)) - default: - return fmt.Sprintf("%dy ago", int(duration.Hours()/24/365)) } + if hours < 24 { + return fmt.Sprintf("%dh ago", int(hours)) + } + if hours < 168 { + return fmt.Sprintf("%dd ago", int(hours/24)) + } + if hours < 720 { + return fmt.Sprintf("%dw ago", int(hours/24/7)) + } + if hours < 8760 { + return fmt.Sprintf("%dmo ago", int(hours/24/30)) + } + return fmt.Sprintf("%dy ago", int(hours/24/365)) } // formatNumber formats large numbers with k/M suffix func formatNumber(n int) string { if n < 1000 { return fmt.Sprintf("%d", n) - } else if n < 1000000 { + } + if n < 1000000 { return fmt.Sprintf("%.1fk", float64(n)/1000) - } else { - return fmt.Sprintf("%.1fM", float64(n)/1000000) } + return fmt.Sprintf("%.1fM", float64(n)/1000000) } // extractMarketplaceSource extracts owner/repo from GitHub URL diff --git a/internal/ui/model.go b/internal/ui/model.go index 884510e..b167c41 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -100,12 +100,14 @@ type Model struct { filterMode FilterMode windowWidth int windowHeight int - copiedFlash bool // Brief "Copied!" indicator (for 'c') - linkCopiedFlash bool // Brief "Link Copied!" indicator (for 'l') - pathCopiedFlash bool // Brief "Path Copied!" indicator (for 'p') - githubOpenedFlash bool // Brief "Opened!" indicator (for 'g') - localOpenedFlash bool // Brief "Opened!" indicator (for 'o') - clipboardErrorFlash bool // Brief "Clipboard error!" indicator + copiedFlash bool // Brief "Copied!" indicator (for 'c') + linkCopiedFlash bool // Brief "Link Copied!" indicator (for 'l') + pathCopiedFlash bool // Brief "Path Copied!" indicator (for 'p') + githubOpenedFlash bool // Brief "Opened!" indicator (for 'g') + localOpenedFlash bool // Brief "Opened!" indicator (for 'o') + clipboardErrorFlash bool // Brief "Clipboard error!" indicator + breadcrumbText string // Context breadcrumb (e.g., "← from Marketplace Browser") + breadcrumbShown bool // Whether to show breadcrumb // Marketplace view state marketplaceItems []MarketplaceItem @@ -136,7 +138,7 @@ type Model struct { // NewModel creates a new Model with initial state func NewModel() Model { ti := textinput.New() - ti.Placeholder = "Search plugins..." + ti.Placeholder = "Search plugins (or @marketplace to filter)..." ti.Focus() ti.CharLimit = 100 ti.Width = 40 @@ -302,20 +304,22 @@ func (m *Model) UpdateScroll() { return } - // Cursor too close to top - scroll up if m.cursor < m.scrollOffset+scrollBuffer { m.scrollOffset = m.cursor - scrollBuffer if m.scrollOffset < 0 { m.scrollOffset = 0 } + return } - // Cursor too close to bottom - scroll down if m.cursor >= m.scrollOffset+maxVisible-scrollBuffer { m.scrollOffset = m.cursor - maxVisible + scrollBuffer + 1 if m.scrollOffset > len(m.results)-maxVisible { m.scrollOffset = len(m.results) - maxVisible } + if m.scrollOffset < 0 { + m.scrollOffset = 0 + } } } @@ -339,7 +343,6 @@ func (m *Model) ToggleDisplayMode() { } else { m.displayMode = DisplayCard } - // Reset scroll to keep cursor visible with new item heights m.UpdateScroll() } @@ -438,26 +441,25 @@ func (m Model) FilterModeName() string { return FilterModeNames[m.filterMode] } -// ReadyCount returns count of ready-to-install plugins (marketplace installed, plugin not) +// ReadyCount returns count of ready-to-install plugins func (m Model) ReadyCount() int { - count := 0 - for _, p := range m.allPlugins { - if !p.Installed && !p.IsDiscoverable { - count++ - } - } - return count + return m.countPlugins(func(p plugin.Plugin) bool { + return !p.Installed && !p.IsDiscoverable + }) } -// DiscoverableCount returns count of discoverable plugins (from uninstalled marketplaces) +// DiscoverableCount returns count of discoverable plugins func (m Model) DiscoverableCount() int { - count := 0 - for _, p := range m.allPlugins { - if p.IsDiscoverable { - count++ - } - } - return count + return m.countPlugins(func(p plugin.Plugin) bool { + return p.IsDiscoverable + }) +} + +// InstalledCount returns count of installed plugins +func (m Model) InstalledCount() int { + return m.countPlugins(func(p plugin.Plugin) bool { + return p.Installed + }) } // TotalPlugins returns total plugin count @@ -465,11 +467,10 @@ func (m Model) TotalPlugins() int { return len(m.allPlugins) } -// InstalledCount returns count of installed plugins -func (m Model) InstalledCount() int { +func (m Model) countPlugins(predicate func(plugin.Plugin) bool) int { count := 0 for _, p := range m.allPlugins { - if p.Installed { + if predicate(p) { count++ } } @@ -574,15 +575,7 @@ func (m *Model) LoadMarketplaceItems() error { if installed != nil { for fullName := range installed.Plugins { // fullName format: "plugin@marketplace" - parts := []string{fullName} - if idx := len(fullName) - 1; idx >= 0 { - for i := len(fullName) - 1; i >= 0; i-- { - if fullName[i] == '@' { - parts = []string{fullName[:i], fullName[i+1:]} - break - } - } - } + parts := strings.SplitN(fullName, "@", 2) if len(parts) == 2 { installedByMarketplace[parts[1]]++ } @@ -732,12 +725,12 @@ func (m *Model) UpdateMarketplaceScroll() { return } - // Keep cursor visible with buffer if m.marketplaceCursor < m.marketplaceScrollOffset+scrollBuffer { m.marketplaceScrollOffset = m.marketplaceCursor - scrollBuffer if m.marketplaceScrollOffset < 0 { m.marketplaceScrollOffset = 0 } + return } if m.marketplaceCursor >= m.marketplaceScrollOffset+maxVisible-scrollBuffer { diff --git a/internal/ui/update.go b/internal/ui/update.go index 15b6c90..41f1370 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -4,6 +4,7 @@ import ( "fmt" "os/exec" "runtime" + "strings" "time" "github.com/atotto/clipboard" @@ -54,45 +55,40 @@ type clearLocalOpenedFlashMsg struct{} // clearClipboardErrorMsg clears the "Clipboard error!" indicator type clearClipboardErrorMsg struct{} -// clearCopiedFlash returns a command that clears the flash after a delay +// clearBreadcrumbMsg clears the breadcrumb indicator +type clearBreadcrumbMsg struct{} + func clearCopiedFlash() tea.Cmd { - return tea.Tick(time.Second*2, func(t time.Time) tea.Msg { - return clearCopiedFlashMsg{} - }) + return clearFlashAfter(2*time.Second, clearCopiedFlashMsg{}) } -// clearLinkCopiedFlash returns a command that clears the flash after a delay func clearLinkCopiedFlash() tea.Cmd { - return tea.Tick(time.Second*2, func(t time.Time) tea.Msg { - return clearLinkCopiedFlashMsg{} - }) + return clearFlashAfter(2*time.Second, clearLinkCopiedFlashMsg{}) } -// clearPathCopiedFlash returns a command that clears the flash after a delay func clearPathCopiedFlash() tea.Cmd { - return tea.Tick(time.Second*2, func(t time.Time) tea.Msg { - return clearPathCopiedFlashMsg{} - }) + return clearFlashAfter(2*time.Second, clearPathCopiedFlashMsg{}) } -// clearGithubOpenedFlash returns a command that clears the flash after a delay func clearGithubOpenedFlash() tea.Cmd { - return tea.Tick(time.Second*2, func(t time.Time) tea.Msg { - return clearGithubOpenedFlashMsg{} - }) + return clearFlashAfter(2*time.Second, clearGithubOpenedFlashMsg{}) } -// clearLocalOpenedFlash returns a command that clears the flash after a delay func clearLocalOpenedFlash() tea.Cmd { - return tea.Tick(time.Second*2, func(t time.Time) tea.Msg { - return clearLocalOpenedFlashMsg{} - }) + return clearFlashAfter(2*time.Second, clearLocalOpenedFlashMsg{}) } -// clearClipboardError returns a command that clears the error flash after a delay func clearClipboardError() tea.Cmd { - return tea.Tick(time.Second*3, func(t time.Time) tea.Msg { - return clearClipboardErrorMsg{} + return clearFlashAfter(3*time.Second, clearClipboardErrorMsg{}) +} + +func clearBreadcrumbAfter(d time.Duration) tea.Cmd { + return clearFlashAfter(d, clearBreadcrumbMsg{}) +} + +func clearFlashAfter(duration time.Duration, msg tea.Msg) tea.Cmd { + return tea.Tick(duration, func(t time.Time) tea.Msg { + return msg }) } @@ -127,83 +123,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.windowHeight = msg.Height m.textInput.Width = msg.Width - 10 - // Initialize/update help viewport - viewportWidth := 58 - - if m.helpViewport.Width == 0 { - // Initial creation - viewportHeight := msg.Height - 9 - if viewportHeight < 5 { - viewportHeight = 5 - } - m.helpViewport = viewport.New(viewportWidth, viewportHeight) - } else { - // Always update width - m.helpViewport.Width = viewportWidth - - // If in help view, recalculate height based on content + terminal size - if m.viewState == ViewHelp { - sectionsContent := m.generateHelpSections() - contentHeight := lipgloss.Height(sectionsContent) - maxHeight := msg.Height - 9 - - if maxHeight < 3 { - maxHeight = 3 - } - - // Resize viewport to fit - if contentHeight < maxHeight { - m.helpViewport.Height = contentHeight - } else { - m.helpViewport.Height = maxHeight - } - - // Re-set content to update wrapping - m.helpViewport.SetContent(sectionsContent) - } - } - - // Initialize/update detail viewport - detailViewportWidth := m.ContentWidth() - 10 - if detailViewportWidth < 40 { - detailViewportWidth = 40 - } - - if m.detailViewport.Width == 0 { - // Initial creation - // Overhead: header(2) + footer(1) + box border(2) + box padding(2) + buffer(2) = 9 - viewportHeight := msg.Height - 9 - if viewportHeight < 5 { - viewportHeight = 5 - } - m.detailViewport = viewport.New(detailViewportWidth, viewportHeight) - } else { - // Always update width - m.detailViewport.Width = detailViewportWidth - - // If in detail view, recalculate height based on content + terminal size - if m.viewState == ViewDetail { - if p := m.SelectedPlugin(); p != nil { - detailContent := m.generateDetailContent(p, detailViewportWidth) - contentHeight := lipgloss.Height(detailContent) - maxHeight := msg.Height - 9 // Match help menu overhead - - if maxHeight < 3 { - maxHeight = 3 - } - - // Resize viewport to fit - if contentHeight < maxHeight { - m.detailViewport.Height = contentHeight - } else { - m.detailViewport.Height = maxHeight - } - - // Re-set content to update wrapping - m.detailViewport.SetContent(detailContent) - } - } - } + (&m).initOrUpdateHelpViewport(msg.Height) + (&m).initOrUpdateDetailViewport(msg.Height) return m, nil @@ -292,6 +213,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.localOpenedFlash = false return m, nil + case clearBreadcrumbMsg: + m.breadcrumbShown = false + return m, nil + case clearClipboardErrorMsg: m.clipboardErrorFlash = false return m, nil @@ -300,6 +225,82 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) initOrUpdateHelpViewport(terminalHeight int) { + const viewportWidth = 58 + const overhead = 9 + + if m.helpViewport.Width == 0 { + viewportHeight := terminalHeight - overhead + if viewportHeight < 3 { + viewportHeight = 3 + } + if viewportHeight > terminalHeight-4 { + viewportHeight = terminalHeight - 4 + } + m.helpViewport = viewport.New(viewportWidth, viewportHeight) + return + } + + m.helpViewport.Width = viewportWidth + + if m.viewState == ViewHelp { + sectionsContent := m.generateHelpSections() + contentHeight := lipgloss.Height(sectionsContent) + maxHeight := terminalHeight - overhead + if maxHeight < 3 { + maxHeight = 3 + } + + if contentHeight < maxHeight { + m.helpViewport.Height = contentHeight + } else { + m.helpViewport.Height = maxHeight + } + + m.helpViewport.SetContent(sectionsContent) + } +} + +func (m *Model) initOrUpdateDetailViewport(terminalHeight int) { + const overhead = 9 + const minWidth = 40 + + detailViewportWidth := m.ContentWidth() - 10 + if detailViewportWidth < minWidth { + detailViewportWidth = minWidth + } + + if m.detailViewport.Width == 0 { + viewportHeight := terminalHeight - overhead + if viewportHeight < 5 { + viewportHeight = 5 + } + m.detailViewport = viewport.New(detailViewportWidth, viewportHeight) + return + } + + m.detailViewport.Width = detailViewportWidth + + if m.viewState == ViewDetail { + if p := m.SelectedPlugin(); p != nil { + detailContent := m.generateDetailContent(p, detailViewportWidth) + contentHeight := lipgloss.Height(detailContent) + maxHeight := terminalHeight - overhead + if maxHeight < 3 { + maxHeight = 3 + } + + if contentHeight < maxHeight { + m.detailViewport.Height = contentHeight + } else { + m.detailViewport.Height = maxHeight + } + + m.detailViewport.SetContent(detailContent) + } + } +} + // handleKeyMsg handles keyboard input func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Global keys @@ -542,63 +543,39 @@ func (m Model) handleDetailKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, animationTick() case "c": - // Copy marketplace install command (for discoverable) or plugin install (for normal) if p := m.SelectedPlugin(); p != nil && !p.Installed { var copyText string if p.IsDiscoverable { - // Copy marketplace add command for discoverable plugins copyText = fmt.Sprintf("/plugin marketplace add %s", p.MarketplaceSource) } else { - // Copy plugin install command for normal plugins copyText = p.InstallCommand() } if err := clipboard.WriteAll(copyText); err == nil { m.copiedFlash = true return m, clearCopiedFlash() - } else { - // Show error to user instead of silently failing - m.clipboardErrorFlash = true - return m, clearClipboardError() } + m.clipboardErrorFlash = true + return m, clearClipboardError() } return m, nil case "y": - // Copy plugin install command (only for discoverable plugins) if p := m.SelectedPlugin(); p != nil && !p.Installed && p.IsDiscoverable { if err := clipboard.WriteAll(p.InstallCommand()); err == nil { m.copiedFlash = true return m, clearCopiedFlash() - } else { - // Show error to user instead of silently failing - m.clipboardErrorFlash = true - return m, clearClipboardError() } + m.clipboardErrorFlash = true + return m, clearClipboardError() } return m, nil case "g": - // Open plugin GitHub URL in browser if p := m.SelectedPlugin(); p != nil { url := p.GitHubURL() - if url != "" { - // Open in browser (cross-platform) - var cmd string - var args []string - switch runtime.GOOS { - case "darwin": - cmd = "open" - args = []string{url} - case "windows": - cmd = "cmd" - args = []string{"/c", "start", url} - default: // linux, bsd, etc. - cmd = "xdg-open" - args = []string{url} - } - // #nosec G204 -- cmd is determined by runtime.GOOS (trusted), args is plugin GitHub URL (validated) - _ = exec.Command(cmd, args...).Start() + if url != "" && strings.HasPrefix(url, "https://github.com/") { + openURL(url) m.githubOpenedFlash = true return m, clearGithubOpenedFlash() } @@ -622,24 +599,8 @@ func (m Model) handleDetailKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "o": - // Open local install directory (only for installed plugins) if p := m.SelectedPlugin(); p != nil && p.Installed && p.InstallPath != "" { - // Open in file manager (cross-platform) - var cmd string - var args []string - switch runtime.GOOS { - case "darwin": - cmd = "open" - args = []string{p.InstallPath} - case "windows": - cmd = "explorer" - args = []string{p.InstallPath} - default: // linux, bsd, etc. - cmd = "xdg-open" - args = []string{p.InstallPath} - } - // #nosec G204 -- cmd is determined by runtime.GOOS (trusted), args is install path from config (validated) - _ = exec.Command(cmd, args...).Start() + openPath(p.InstallPath) m.localOpenedFlash = true return m, clearLocalOpenedFlash() } @@ -722,7 +683,9 @@ func (m Model) handleMarketplaceListKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": if len(m.marketplaceItems) > 0 && m.marketplaceCursor < len(m.marketplaceItems) { - m.selectedMarketplace = &m.marketplaceItems[m.marketplaceCursor] + // Create a copy to avoid holding a pointer to a slice element + item := m.marketplaceItems[m.marketplaceCursor] + m.selectedMarketplace = &item m.StartViewTransition(ViewMarketplaceDetail, 1) return m, animationTick() } @@ -738,8 +701,10 @@ func (m Model) handleMarketplaceListKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc", "ctrl+g": // Return to plugin list view + m.breadcrumbText = "← from Marketplace Browser" + m.breadcrumbShown = true m.StartViewTransition(ViewList, -1) - return m, animationTick() + return m, tea.Batch(animationTick(), clearBreadcrumbAfter(2*time.Second)) case "?": m.StartViewTransition(ViewHelp, 1) @@ -760,26 +725,21 @@ func (m Model) handleMarketplaceDetailKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) return m, animationTick() case "c": - // Copy marketplace install command - if m.selectedMarketplace != nil && - m.selectedMarketplace.Status != MarketplaceInstalled { + if m.selectedMarketplace != nil && m.selectedMarketplace.Status != MarketplaceInstalled { installCmd := fmt.Sprintf("/plugin marketplace add %s", extractMarketplaceSource(m.selectedMarketplace.Repo)) if err := clipboard.WriteAll(installCmd); err == nil { m.copiedFlash = true return m, clearCopiedFlash() - } else { - m.clipboardErrorFlash = true - return m, clearClipboardError() } + m.clipboardErrorFlash = true + return m, clearClipboardError() } return m, nil case "f": - // Filter plugins by this marketplace m.previousViewBeforeMarketplace = ViewList m.StartViewTransition(ViewList, -1) - // Set search to filter by marketplace m.textInput.SetValue("@" + m.selectedMarketplace.Name) m.results = m.filteredSearch(m.textInput.Value()) m.cursor = 0 @@ -787,26 +747,13 @@ func (m Model) handleMarketplaceDetailKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) return m, animationTick() case "g": - // Open GitHub repo if m.selectedMarketplace != nil { url := m.selectedMarketplace.Repo - var cmd string - var args []string - switch runtime.GOOS { - case "darwin": - cmd = "open" - args = []string{url} - case "windows": - cmd = "cmd" - args = []string{"/c", "start", url} - default: - cmd = "xdg-open" - args = []string{url} + if strings.HasPrefix(url, "https://github.com/") { + openURL(url) + m.githubOpenedFlash = true + return m, clearGithubOpenedFlash() } - // #nosec G204 -- cmd is determined by runtime.GOOS (trusted), args is marketplace repo URL (from registry) - _ = exec.Command(cmd, args...).Start() - m.githubOpenedFlash = true - return m, clearGithubOpenedFlash() } return m, nil @@ -820,3 +767,43 @@ func (m Model) handleMarketplaceDetailKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) return m, nil } + +func openURL(url string) { + var cmd string + var args []string + + switch runtime.GOOS { + case "darwin": + cmd = "open" + args = []string{url} + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + default: + cmd = "xdg-open" + args = []string{url} + } + + // #nosec G204 -- cmd is determined by runtime.GOOS (trusted), args is validated URL + _ = exec.Command(cmd, args...).Start() +} + +func openPath(path string) { + var cmd string + var args []string + + switch runtime.GOOS { + case "darwin": + cmd = "open" + args = []string{path} + case "windows": + cmd = "explorer" + args = []string{path} + default: + cmd = "xdg-open" + args = []string{path} + } + + // #nosec G204 -- cmd is determined by runtime.GOOS (trusted), args is install path from config + _ = exec.Command(cmd, args...).Start() +} diff --git a/internal/ui/view.go b/internal/ui/view.go index d7f10a6..2662fb6 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -206,7 +206,18 @@ func (m Model) listView() string { // Filter tabs b.WriteString(m.renderFilterTabs()) - b.WriteString("\n\n") + b.WriteString("\n") + + // Breadcrumb (context from previous view) + if m.breadcrumbShown && m.breadcrumbText != "" { + breadcrumbStyle := lipgloss.NewStyle(). + Foreground(TextMuted). + Italic(true). + Padding(0, 1) + b.WriteString(breadcrumbStyle.Render(m.breadcrumbText)) + b.WriteString("\n") + } + b.WriteString("\n") // Results if m.loading { @@ -388,6 +399,16 @@ func (m Model) statusBar() string { position = "0/0" } + // Check if marketplace filter is active + query := m.textInput.Value() + var marketplaceFilter string + if strings.HasPrefix(query, "@") { + marketplaceName := strings.TrimPrefix(query, "@") + if marketplaceName != "" { + marketplaceFilter = fmt.Sprintf("@%s (%d results)", marketplaceName, len(m.results)) + } + } + // Opposite view mode name for the toggle hint var oppositeView string if m.displayMode == DisplaySlim { @@ -404,32 +425,48 @@ func (m Model) statusBar() string { switch { case useVerbose: // Verbose: full descriptions (only in card/verbose mode) - parts = append(parts, position+" "+m.FilterModeName()) + if marketplaceFilter != "" { + parts = append(parts, marketplaceFilter) + } else { + parts = append(parts, position+" "+m.FilterModeName()) + } parts = append(parts, KeyStyle.Render("↑↓/ctrl+jk")+" navigate") - parts = append(parts, KeyStyle.Render("tab")+" filter") + parts = append(parts, KeyStyle.Render("tab")+" next view") parts = append(parts, KeyStyle.Render("Shift+V")+" "+oppositeView) parts = append(parts, KeyStyle.Render("enter")+" details") parts = append(parts, KeyStyle.Render("?")) case width >= 70: // Standard: concise but complete - parts = append(parts, position) + if marketplaceFilter != "" { + parts = append(parts, marketplaceFilter) + } else { + parts = append(parts, position) + } parts = append(parts, KeyStyle.Render("↑↓")+" nav") - parts = append(parts, KeyStyle.Render("tab")+" filter") + parts = append(parts, KeyStyle.Render("tab")+" next view") parts = append(parts, KeyStyle.Render("Shift+M")+" marketplaces") parts = append(parts, KeyStyle.Render("Shift+V")+" "+oppositeView) parts = append(parts, KeyStyle.Render("?")+" help") case width >= 50: // Compact: essentials only - parts = append(parts, position) + if marketplaceFilter != "" { + parts = append(parts, marketplaceFilter) + } else { + parts = append(parts, position) + } parts = append(parts, KeyStyle.Render("↑↓")+" nav") - parts = append(parts, KeyStyle.Render("tab")+" filter") + parts = append(parts, KeyStyle.Render("tab")+" next view") parts = append(parts, KeyStyle.Render("?")+" help") default: // Minimal: bare minimum - parts = append(parts, position) + if marketplaceFilter != "" { + parts = append(parts, marketplaceFilter) + } else { + parts = append(parts, position) + } parts = append(parts, KeyStyle.Render("?")+"=help") } From 39671c84553ef4cd1cc38cbaf4ba68df0a8a7a4f Mon Sep 17 00:00:00 2001 From: itsdevcoffee Date: Wed, 4 Feb 2026 03:44:09 -0500 Subject: [PATCH 2/7] refactor: remove breadcrumb feature based on user feedback The breadcrumb was just a temporary text indicator that didn't provide real navigation value. User feedback indicated expectation of a proper navigation history stack (back/forward), which would be a larger feature. Keeping the 3 core improvements: - Unified terminology (next view vs change order) - @marketplace filtering discoverability - Contextual help view --- internal/ui/model.go | 14 ++++++-------- internal/ui/update.go | 15 +-------------- internal/ui/view.go | 13 +------------ 3 files changed, 8 insertions(+), 34 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index b167c41..8d81474 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -100,14 +100,12 @@ type Model struct { filterMode FilterMode windowWidth int windowHeight int - copiedFlash bool // Brief "Copied!" indicator (for 'c') - linkCopiedFlash bool // Brief "Link Copied!" indicator (for 'l') - pathCopiedFlash bool // Brief "Path Copied!" indicator (for 'p') - githubOpenedFlash bool // Brief "Opened!" indicator (for 'g') - localOpenedFlash bool // Brief "Opened!" indicator (for 'o') - clipboardErrorFlash bool // Brief "Clipboard error!" indicator - breadcrumbText string // Context breadcrumb (e.g., "← from Marketplace Browser") - breadcrumbShown bool // Whether to show breadcrumb + copiedFlash bool // Brief "Copied!" indicator (for 'c') + linkCopiedFlash bool // Brief "Link Copied!" indicator (for 'l') + pathCopiedFlash bool // Brief "Path Copied!" indicator (for 'p') + githubOpenedFlash bool // Brief "Opened!" indicator (for 'g') + localOpenedFlash bool // Brief "Opened!" indicator (for 'o') + clipboardErrorFlash bool // Brief "Clipboard error!" indicator // Marketplace view state marketplaceItems []MarketplaceItem diff --git a/internal/ui/update.go b/internal/ui/update.go index 41f1370..9095726 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -55,9 +55,6 @@ type clearLocalOpenedFlashMsg struct{} // clearClipboardErrorMsg clears the "Clipboard error!" indicator type clearClipboardErrorMsg struct{} -// clearBreadcrumbMsg clears the breadcrumb indicator -type clearBreadcrumbMsg struct{} - func clearCopiedFlash() tea.Cmd { return clearFlashAfter(2*time.Second, clearCopiedFlashMsg{}) } @@ -82,10 +79,6 @@ func clearClipboardError() tea.Cmd { return clearFlashAfter(3*time.Second, clearClipboardErrorMsg{}) } -func clearBreadcrumbAfter(d time.Duration) tea.Cmd { - return clearFlashAfter(d, clearBreadcrumbMsg{}) -} - func clearFlashAfter(duration time.Duration, msg tea.Msg) tea.Cmd { return tea.Tick(duration, func(t time.Time) tea.Msg { return msg @@ -213,10 +206,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.localOpenedFlash = false return m, nil - case clearBreadcrumbMsg: - m.breadcrumbShown = false - return m, nil - case clearClipboardErrorMsg: m.clipboardErrorFlash = false return m, nil @@ -701,10 +690,8 @@ func (m Model) handleMarketplaceListKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc", "ctrl+g": // Return to plugin list view - m.breadcrumbText = "← from Marketplace Browser" - m.breadcrumbShown = true m.StartViewTransition(ViewList, -1) - return m, tea.Batch(animationTick(), clearBreadcrumbAfter(2*time.Second)) + return m, animationTick() case "?": m.StartViewTransition(ViewHelp, 1) diff --git a/internal/ui/view.go b/internal/ui/view.go index 2662fb6..22df55e 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -206,18 +206,7 @@ func (m Model) listView() string { // Filter tabs b.WriteString(m.renderFilterTabs()) - b.WriteString("\n") - - // Breadcrumb (context from previous view) - if m.breadcrumbShown && m.breadcrumbText != "" { - breadcrumbStyle := lipgloss.NewStyle(). - Foreground(TextMuted). - Italic(true). - Padding(0, 1) - b.WriteString(breadcrumbStyle.Render(m.breadcrumbText)) - b.WriteString("\n") - } - b.WriteString("\n") + b.WriteString("\n\n") // Results if m.loading { From 749064f7a4a04c84e56d5103be96c108b9a729fe Mon Sep 17 00:00:00 2001 From: itsdevcoffee Date: Wed, 4 Feb 2026 04:44:12 -0500 Subject: [PATCH 3/7] feat: add intelligent marketplace autocomplete with picker UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements sophisticated @marketplace filtering with autocomplete UX based on user feedback during testing. Key Features: - Marketplace picker appears when typing @ (replaces "no plugins found") - Arrow keys navigate marketplace list - Enter selects marketplace and autocompletes @marketplace-name - Pattern: @marketplace-name [search terms] for fuzzy search within marketplace - Example: "@claude-code-plugins test" searches for "test" in that marketplace UX Improvements: - Placeholder updated: "or @marketplace-name to filter" (more specific) - Picker shows marketplace names with plugin counts - Helpful hint: "↑↓ to navigate • Enter to select • Keep typing to filter" - Autocomplete deactivates when user adds search terms after marketplace name Implementation: - Added marketplaceAutocompleteActive, marketplaceAutocompleteList, marketplaceAutocompleteCursor to Model - UpdateMarketplaceAutocomplete() detects @ mode and filters marketplace list - SelectMarketplaceAutocomplete() completes the marketplace name - Enhanced filteredSearch() to parse "@marketplace-name search" pattern - Arrow keys and Enter intercepted when autocomplete active - renderMarketplaceAutocomplete() displays marketplace picker Note: Syntax coloring for @marketplace deferred (requires textinput cursor customization) Closes user feedback: confusing "no plugins found" when typing @ --- internal/ui/model.go | 88 +++++++++++++++++++++++++++++++++++++++---- internal/ui/update.go | 31 ++++++++++++++- internal/ui/view.go | 62 +++++++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 10 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 8d81474..3f3909b 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -115,6 +115,11 @@ type Model struct { selectedMarketplace *MarketplaceItem previousViewBeforeMarketplace ViewState + // Marketplace autocomplete state (for @marketplace-name filtering) + marketplaceAutocompleteActive bool // True when showing marketplace picker + marketplaceAutocompleteList []MarketplaceItem // Filtered marketplaces for autocomplete + marketplaceAutocompleteCursor int // Selected index in autocomplete list + // Animation state cursorY float64 // Animated cursor position cursorYVelocity float64 @@ -136,7 +141,7 @@ type Model struct { // NewModel creates a new Model with initial state func NewModel() Model { ti := textinput.New() - ti.Placeholder = "Search plugins (or @marketplace to filter)..." + ti.Placeholder = "Search plugins (or @marketplace-name to filter)..." ti.Focus() ti.CharLimit = 100 ti.Width = 40 @@ -384,16 +389,35 @@ func (m *Model) applyFilter() { func (m Model) filteredSearch(query string) []search.RankedPlugin { // Check for marketplace filter (starts with @) if strings.HasPrefix(query, "@") { - marketplaceName := strings.TrimPrefix(query, "@") - var filtered []search.RankedPlugin + // Parse: @marketplace-name [optional search terms] + parts := strings.SplitN(query[1:], " ", 2) + marketplaceName := parts[0] + searchTerms := "" + if len(parts) > 1 { + searchTerms = parts[1] + } + + // Filter plugins by marketplace + var marketplacePlugins []plugin.Plugin for _, p := range m.allPlugins { if p.Marketplace == marketplaceName { - filtered = append(filtered, search.RankedPlugin{ - Plugin: p, - Score: 1.0, - }) + marketplacePlugins = append(marketplacePlugins, p) } } + + // If there are search terms, fuzzy search within the marketplace + if searchTerms != "" { + return search.Search(searchTerms, marketplacePlugins) + } + + // Otherwise return all plugins from this marketplace + var filtered []search.RankedPlugin + for _, p := range marketplacePlugins { + filtered = append(filtered, search.RankedPlugin{ + Plugin: p, + Score: 1.0, + }) + } return filtered } @@ -754,3 +778,53 @@ func (m *Model) PrevMarketplaceSort() { m.marketplaceCursor = 0 m.marketplaceScrollOffset = 0 } + +// UpdateMarketplaceAutocomplete updates the marketplace autocomplete list based on query +func (m *Model) UpdateMarketplaceAutocomplete(query string) { + // Extract marketplace filter part (everything after @ until first space) + if !strings.HasPrefix(query, "@") { + m.marketplaceAutocompleteActive = false + return + } + + // Find first space to separate marketplace name from search terms + parts := strings.SplitN(query[1:], " ", 2) + marketplaceFilter := parts[0] + + // If there's a space and search terms after, exit autocomplete mode + if len(parts) > 1 && parts[1] != "" { + m.marketplaceAutocompleteActive = false + return + } + + // We're in autocomplete mode - filter marketplaces + m.marketplaceAutocompleteActive = true + m.marketplaceAutocompleteList = []MarketplaceItem{} + + for _, item := range m.marketplaceItems { + // Match on marketplace name (case-insensitive) + if marketplaceFilter == "" || strings.Contains(strings.ToLower(item.Name), strings.ToLower(marketplaceFilter)) { + m.marketplaceAutocompleteList = append(m.marketplaceAutocompleteList, item) + } + } + + // Reset cursor if out of bounds + if m.marketplaceAutocompleteCursor >= len(m.marketplaceAutocompleteList) { + m.marketplaceAutocompleteCursor = 0 + } +} + +// SelectMarketplaceAutocomplete completes the marketplace name in the search box +func (m *Model) SelectMarketplaceAutocomplete() { + if !m.marketplaceAutocompleteActive || len(m.marketplaceAutocompleteList) == 0 { + return + } + + selected := m.marketplaceAutocompleteList[m.marketplaceAutocompleteCursor] + m.textInput.SetValue("@" + selected.Name + " ") + m.marketplaceAutocompleteActive = false + m.marketplaceAutocompleteCursor = 0 + + // Move cursor to end + m.textInput.SetCursor(len(m.textInput.Value())) +} diff --git a/internal/ui/update.go b/internal/ui/update.go index 9095726..45a295d 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -321,6 +321,14 @@ func (m Model) handleListKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { // Navigation: Ctrl + j/k/n/p or arrow keys case "up", "ctrl+k", "ctrl+p": + // Handle marketplace autocomplete navigation + if m.marketplaceAutocompleteActive { + if m.marketplaceAutocompleteCursor > 0 { + m.marketplaceAutocompleteCursor-- + } + return m, nil + } + if m.cursor > 0 { m.cursor-- } @@ -329,6 +337,14 @@ func (m Model) handleListKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, animationTick() case "down", "ctrl+j", "ctrl+n": + // Handle marketplace autocomplete navigation + if m.marketplaceAutocompleteActive { + if m.marketplaceAutocompleteCursor < len(m.marketplaceAutocompleteList)-1 { + m.marketplaceAutocompleteCursor++ + } + return m, nil + } + if m.cursor < len(m.results)-1 { m.cursor++ } @@ -375,6 +391,13 @@ func (m Model) handleListKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Actions case "enter": + // Handle marketplace autocomplete selection + if m.marketplaceAutocompleteActive { + m.SelectMarketplaceAutocomplete() + m.results = m.filteredSearch(m.textInput.Value()) + return m, nil + } + if len(m.results) > 0 { // Set detail viewport content before transition (like help menu) if m.detailViewport.Width > 0 { @@ -498,13 +521,19 @@ func (m Model) handleListKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.textInput, cmd = m.textInput.Update(msg) newValue := m.textInput.Value() + // Update marketplace autocomplete state + m.UpdateMarketplaceAutocomplete(newValue) + // Re-run search on input change (with filter) - m.results = m.filteredSearch(newValue) + if !m.marketplaceAutocompleteActive { + m.results = m.filteredSearch(newValue) + } // Reset cursor to top on any search input change if newValue != oldValue { m.cursor = 0 m.scrollOffset = 0 + m.marketplaceAutocompleteCursor = 0 m.SnapCursorToTarget() } else if m.cursor >= len(m.results) { // Clamp cursor if somehow out of bounds diff --git a/internal/ui/view.go b/internal/ui/view.go index 22df55e..a2d8dff 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -200,8 +200,8 @@ func (m Model) listView() string { b.WriteString(TitleStyle.Render(title)) b.WriteString("\n\n") - // Search input - b.WriteString(m.textInput.View()) + // Search input with custom styling for @marketplace syntax + b.WriteString(m.renderSearchInput()) b.WriteString("\n") // Filter tabs @@ -228,6 +228,9 @@ func (m Model) listView() string { } } else if len(m.allPlugins) == 0 { b.WriteString(DescriptionStyle.Render("No plugins found.")) + } else if m.marketplaceAutocompleteActive { + // Show marketplace picker for autocomplete + b.WriteString(m.renderMarketplaceAutocomplete()) } else if len(m.results) == 0 { b.WriteString(DescriptionStyle.Render("No plugins found matching your search.")) } else { @@ -250,6 +253,61 @@ func (m Model) listView() string { } // renderPluginItem renders a single plugin item based on display mode +// renderSearchInput renders the search input with custom styling for @marketplace syntax +func (m Model) renderSearchInput() string { + // For now, use normal text input rendering + // TODO: Add custom @ syntax coloring (requires workaround for textinput cursor) + return m.textInput.View() +} + +// renderMarketplaceAutocomplete renders the marketplace picker for autocomplete +func (m Model) renderMarketplaceAutocomplete() string { + var b strings.Builder + + // Header + headerStyle := lipgloss.NewStyle().Foreground(PeachSoft).Bold(true) + b.WriteString(headerStyle.Render("Select marketplace:")) + b.WriteString("\n\n") + + // Render marketplace list + if len(m.marketplaceAutocompleteList) == 0 { + b.WriteString(DescriptionStyle.Render("No marketplaces found.")) + } else { + for i, item := range m.marketplaceAutocompleteList { + isSelected := i == m.marketplaceAutocompleteCursor + + // Selection prefix + var prefix string + if isSelected { + prefix = HighlightBarFull.String() + } else { + prefix = " " + } + + // Name style + var nameStyle lipgloss.Style + if isSelected { + nameStyle = PluginNameSelectedStyle + } else { + nameStyle = PluginNameStyle + } + + name := nameStyle.Render(item.DisplayName) + + // Plugin count + pluginCount := lipgloss.NewStyle().Foreground(TextTertiary).Render( + fmt.Sprintf("(%d plugins)", item.TotalPluginCount)) + + b.WriteString(fmt.Sprintf("%s%s %s\n", prefix, name, pluginCount)) + } + } + + hint := HelpStyle.Render("\n↑↓ to navigate • Enter to select • Keep typing to filter") + b.WriteString(hint) + + return b.String() +} + func (m Model) renderPluginItem(p plugin.Plugin, selected bool) string { if m.displayMode == DisplaySlim { return m.renderPluginItemSlim(p, selected) From 65f99c3549e99c80b4adadd2787dd95f375beb9a Mon Sep 17 00:00:00 2001 From: itsdevcoffee Date: Wed, 4 Feb 2026 04:47:57 -0500 Subject: [PATCH 4/7] fix: lazy-load marketplace items for autocomplete Marketplace items weren't loaded at startup, causing 'No marketplaces found' when typing @. Now lazy-loads marketplace data on first @ keypress. --- internal/ui/model.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index 3f3909b..8141d11 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -797,6 +797,11 @@ func (m *Model) UpdateMarketplaceAutocomplete(query string) { return } + // Lazy-load marketplace items if not already loaded + if len(m.marketplaceItems) == 0 { + _ = m.LoadMarketplaceItems() + } + // We're in autocomplete mode - filter marketplaces m.marketplaceAutocompleteActive = true m.marketplaceAutocompleteList = []MarketplaceItem{} From fbd338ce003018fb8764f7c0340fc35033e1ff24 Mon Sep 17 00:00:00 2001 From: itsdevcoffee Date: Wed, 4 Feb 2026 05:15:06 -0500 Subject: [PATCH 5/7] fix: autocomplete should exit on any space, not just non-empty search When user types '@marketplace-name ' (with trailing space), should show all plugins from that marketplace, not re-enter autocomplete mode. --- internal/ui/model.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 8141d11..3f9a960 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -791,8 +791,9 @@ func (m *Model) UpdateMarketplaceAutocomplete(query string) { parts := strings.SplitN(query[1:], " ", 2) marketplaceFilter := parts[0] - // If there's a space and search terms after, exit autocomplete mode - if len(parts) > 1 && parts[1] != "" { + // If there's a space (even if empty search after), exit autocomplete mode + // This handles both "@marketplace search" and "@marketplace " (trailing space) + if len(parts) > 1 { m.marketplaceAutocompleteActive = false return } From fccae6fff92b2f206cfbc99cd2601b214c7cd72b Mon Sep 17 00:00:00 2001 From: itsdevcoffee Date: Wed, 4 Feb 2026 05:32:31 -0500 Subject: [PATCH 6/7] feat: add background color to @marketplace-name for visual separation The @marketplace-name filter now displays with: - Burnt orange background (PlumMedium) - White text, bold - Padding for breathing room - Creates clear visual distinction from search terms Example: '@claude-code-plugins test' shows the filter with background and regular search text without, making the active filter obvious. --- internal/ui/view.go | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/ui/view.go b/internal/ui/view.go index a2d8dff..012a156 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -255,8 +255,43 @@ func (m Model) listView() string { // renderPluginItem renders a single plugin item based on display mode // renderSearchInput renders the search input with custom styling for @marketplace syntax func (m Model) renderSearchInput() string { - // For now, use normal text input rendering - // TODO: Add custom @ syntax coloring (requires workaround for textinput cursor) + value := m.textInput.Value() + + // If query starts with @, style the @marketplace-name part with background + if strings.HasPrefix(value, "@") { + // Find first space to separate marketplace from search terms + spaceIdx := strings.Index(value, " ") + + var marketplacePart, searchPart string + if spaceIdx == -1 { + marketplacePart = value + searchPart = "" + } else { + marketplacePart = value[:spaceIdx] + searchPart = value[spaceIdx:] + } + + // Style marketplace part with contrasting background + marketplaceStyle := lipgloss.NewStyle(). + Foreground(TextPrimary). + Background(PlumMedium). + Bold(true). + Padding(0, 1) + + // Render with prompt + promptStyled := SearchPromptStyle.Render(m.textInput.Prompt) + marketplaceStyled := marketplaceStyle.Render(marketplacePart) + + // Add cursor indicator at end if focused + cursorIndicator := "" + if m.textInput.Focused() { + cursorIndicator = lipgloss.NewStyle().Foreground(PlumBright).Render("│") + } + + return promptStyled + marketplaceStyled + searchPart + cursorIndicator + } + + // Normal rendering for non-@ queries return m.textInput.View() } From b2cb77bc9f3868069dd65f0c3928355fa96c906c Mon Sep 17 00:00:00 2001 From: itsdevcoffee Date: Wed, 4 Feb 2026 05:47:40 -0500 Subject: [PATCH 7/7] feat: make filter tab counts dynamic based on search results Filter tab counts now update in real-time as you type or change filters. Before: All (527) | Discover (74) | Ready (444) | Installed (9) (static, never changed) After: All (15) | Discover (3) | Ready (10) | Installed (2) (updates as you search for 'test') Implementation: - Added getDynamicFilterCounts() method - Calculates count for each filter mode based on current query - Runs filteredSearch() for each mode to get accurate counts - Updates in real-time as user types UX Benefit: Users can see at a glance how many results exist in each filter without switching tabs. --- internal/ui/model.go | 16 ++++++++++++++++ internal/ui/view.go | 16 +++++++--------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 3f9a960..ee0adc6 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -463,6 +463,22 @@ func (m Model) FilterModeName() string { return FilterModeNames[m.filterMode] } +// getDynamicFilterCounts calculates counts for each filter mode based on current search query +func (m Model) getDynamicFilterCounts(query string) map[FilterMode]int { + counts := make(map[FilterMode]int) + + // For each filter mode, calculate how many results we'd get + for _, mode := range []FilterMode{FilterAll, FilterDiscover, FilterReady, FilterInstalled} { + // Temporarily set filter mode and get results + tempModel := m + tempModel.filterMode = mode + results := tempModel.filteredSearch(query) + counts[mode] = len(results) + } + + return counts +} + // ReadyCount returns count of ready-to-install plugins func (m Model) ReadyCount() int { return m.countPlugins(func(p plugin.Plugin) bool { diff --git a/internal/ui/view.go b/internal/ui/view.go index 012a156..afd062e 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -152,21 +152,19 @@ func (m Model) renderFilterTabs() string { Foreground(TextTertiary). Padding(0, 1) - // Build tabs with counts - allCount := len(m.allPlugins) - discoverCount := m.DiscoverableCount() - readyCount := m.ReadyCount() - installCount := m.InstalledCount() + // Build tabs with dynamic counts based on current search + query := m.textInput.Value() + counts := m.getDynamicFilterCounts(query) tabs := []struct { name string count int active bool }{ - {"All", allCount, m.filterMode == FilterAll}, - {"Discover", discoverCount, m.filterMode == FilterDiscover}, - {"Ready", readyCount, m.filterMode == FilterReady}, - {"Installed", installCount, m.filterMode == FilterInstalled}, + {"All", counts[FilterAll], m.filterMode == FilterAll}, + {"Discover", counts[FilterDiscover], m.filterMode == FilterDiscover}, + {"Ready", counts[FilterReady], m.filterMode == FilterReady}, + {"Installed", counts[FilterInstalled], m.filterMode == FilterInstalled}, } var parts []string