drag-pane
The drag-pane skill provides a complete implementation pattern for adding click-and-drag pane resizing functionality to two-pane plugin layouts in the sidecar application. Use this when building or updating plugins with a sidebar and main content area, following the prerequisite steps for state persistence and leveraging the internal mouse package to handle divider interactions and width calculations.
git clone --depth 1 https://github.com/marcus/sidecar /tmp/drag-pane && cp -r /tmp/drag-pane/.claude/skills/drag-pane ~/.claude/skills/drag-paneSKILL.md
# Drag-to-Resize Pane Implementation
## Overview
Add drag-to-resize support for two-pane plugin layouts (sidebar + main content). Users click and drag the divider between panes to resize them.
## Prerequisites
- Plugin already has a two-pane layout (sidebar + main content)
- State persistence functions exist in `internal/state/state.go` (each plugin has its own getter/setter)
- Familiarity with `internal/mouse` package
## Existing Implementations
| Plugin | State Functions | Mouse File |
|--------|----------------|------------|
| FileBrowser | `GetFileBrowserTreeWidth()` / `SetFileBrowserTreeWidth()` | `internal/plugins/filebrowser/mouse.go` |
| GitStatus | `GetGitStatusSidebarWidth()` / `SetGitStatusSidebarWidth()` | `internal/plugins/gitstatus/mouse.go` |
| Conversations | `GetConversationsSideWidth()` / `SetConversationsSideWidth()` | `internal/plugins/conversations/mouse.go` |
| Workspace | `GetWorkspaceSidebarWidth()` / `SetWorkspaceSidebarWidth()` | `internal/plugins/workspace/view_list.go` |
## Implementation Steps
### Step 1: Add Mouse Handler to Plugin Struct
```go
import "github.com/marcus/sidecar/internal/mouse"
type Plugin struct {
// ... other fields
mouseHandler *mouse.Handler
sidebarWidth int // Current sidebar width (persisted)
}
func New() *Plugin {
return &Plugin{
mouseHandler: mouse.NewHandler(),
}
}
```
### Step 2: Define Hit Region Constants
```go
const (
regionSidebar = "sidebar"
regionMainPane = "main-pane"
regionPaneDivider = "pane-divider"
dividerWidth = 1 // Visual divider width
)
```
### Step 3: Initialize Width on First Render (NOT in Init)
**Important:** Do NOT load width in `Init()` - plugin dimensions (`p.width`) are not available yet. Initialize lazily on first render:
```go
func (p *Plugin) renderTwoPane() string {
p.mouseHandler.HitMap.Clear() // CRITICAL: clear every render
if p.sidebarWidth == 0 {
p.sidebarWidth = state.GetYourPluginSidebarWidth()
if p.sidebarWidth == 0 {
available := p.width - dividerWidth
p.sidebarWidth = available * 30 / 100 // Default 30%
}
}
// ... rest of render
}
```
### Step 4: Handle MouseMsg in Update
```go
func (p *Plugin) Update(msg tea.Msg) (plugin.Plugin, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseMsg:
return p.handleMouse(msg)
}
}
```
### Step 5: Create mouse.go with Handlers
```go
func (p *Plugin) handleMouse(msg tea.MouseMsg) (*Plugin, tea.Cmd) {
action := p.mouseHandler.HandleMouse(msg)
switch action.Type {
case mouse.ActionClick:
return p.handleMouseClick(action)
case mouse.ActionDrag:
return p.handleMouseDrag(action)
case mouse.ActionDragEnd:
return p.handleMouseDragEnd()
}
return p, nil
}
func (p *Plugin) handleMouseClick(action mouse.MouseAction) (*Plugin, tea.Cmd) {
if action.Region == nil {
return p, nil
}
switch action.Region.ID {
case regionSidebar:
p.activePane = PaneSidebar
case regionMainPane:
p.activePane = PaneMain
case regionPaneDivider:
p.mouseHandler.StartDrag(action.X, action.Y, regionPaneDivider, p.sidebarWidth)
}
return p, nil
}
func (p *Plugin) handleMouseDrag(action mouse.MouseAction) (*Plugin, tea.Cmd) {
if p.mouseHandler.DragRegion() != regionPaneDivider {
return p, nil
}
startValue := p.mouseHandler.DragStartValue()
newWidth := startValue + action.DragDX
// Clamp to bounds
// NOTE: Offset varies by plugin (border styling differences):
// GitStatus: -5, FileBrowser: -6, Conversations: -5, Workspace: just dividerWidth
available := p.width - 5 - dividerWidth
minWidth := 25
maxWidth := available - 40
if newWidth < minWidth {
newWidth = minWidth
} else if newWidth > maxWidth {
newWidth = maxWidth
}
p.sidebarWidth = newWidth
return p, nil
}
func (p *Plugin) handleMouseDragEnd() (*Plugin, tea.Cmd) {
_ = state.SetYourPluginSidebarWidth(p.sidebarWidth)
return p, nil
}
```
### Step 6: Register Hit Regions in Render
**This is where most bugs occur.** Follow this pattern exactly:
```go
func (p *Plugin) renderTwoPane() string {
p.mouseHandler.HitMap.Clear() // CRITICAL: clear every render
available := p.width - 5 - dividerWidth
sidebarWidth := p.sidebarWidth
if sidebarWidth == 0 {
sidebarWidth = available * 30 / 100
}
if sidebarWidth < 25 {
sidebarWidth = 25
}
if sidebarWidth > available-40 {
sidebarWidth = available - 40
}
mainWidth := available - sidebarWidth
p.sidebarWidth = sidebarWidth
// ... render panes and divider ...
// CRITICAL: Register in priority order (last = highest priority)
p.mouseHandler.HitMap.AddRect(regionSidebar, 0, 0, sidebarWidth, p.height, nil)
mainX := sidebarWidth + dividerWidth
p.mouseHandler.HitMap.AddRect(regionMainPane, mainX, 0, mainWidth, p.height, nil)
// Divider LAST = highest priority
dividerX := sidebarWidth
dividerHitWidth := 3 // Wider than visual for easier clicking
p.mouseHandler.HitMap.AddRect(regionPaneDivider, dividerX, 0, dividerHitWidth, p.height, nil)
return content
}
```
### Step 7: Render Visible Divider
```go
func (p *Plugin) renderDivider(height int) string {
dividerStyle := lipgloss.NewStyle().
Foreground(styles.BorderNormal).
MarginTop(1) // Aligns with pane content (below top border)
var sb strings.Builder
for i := 0; i < height; i++ {
sb.WriteString("|")
if i < height-1 {
sb.WriteString("\n")
}
}
return dividerStyle.Render(sb.String())
}
```
### Step 8: Add State Persistence
Add plugin-specific functions to `internal/state/state.go`:
```go
// In State struct
YourPluginSidebarWidth int `json:"yourPluginSidebarWidth,omitempty"`
// Getter
func GetYourPluginSidebarWidth() int {
mu.RLoc>
Create declarative modals using the modal library API. Covers modal types (confirm, input, select, form), sections (Text, Buttons, Input, Textarea, Checkbox, List, When, Custom), rendering with OverlayModal, and keyboard/mouse handling. Use when adding modals or dialogs to the application.
>
Create prompts for sidecar workspaces. Covers prompt structure (name, ticketMode, body), template variables (ticket with fallbacks), config file locations (global vs project), and scope overrides. Use when creating or modifying prompts in sidecar config files.
>
Creating and using feature flags in sidecar for gating experimental functionality. Covers flag registration, checking flags in code, config file and CLI overrides, and priority resolution. Use when adding feature flags, toggling features, or gating new functionality behind flags.
>
>