Agent skill
drag-pane
Drag-and-drop pane resizing implementation for two-pane plugin layouts. Covers mouse event handling via the internal/mouse package, hit region registration, drag delta calculation, width clamping, state persistence, and pane layout management. Use when working on pane resizing, drag interactions, layout management, or adding drag-to-resize to a new plugin.
Install this agent skill to your Project
npx add-skill https://github.com/marcus/sidecar/tree/main/.claude/skills/drag-pane
SKILL.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/mousepackage
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
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
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:
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
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
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:
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
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:
// In State struct
YourPluginSidebarWidth int `json:"yourPluginSidebarWidth,omitempty"`
// Getter
func GetYourPluginSidebarWidth() int {
mu.RLock()
defer mu.RUnlock()
if current == nil { return 0 }
return current.YourPluginSidebarWidth
}
// Setter
func SetYourPluginSidebarWidth(width int) error {
mu.Lock()
if current == nil { current = &State{} }
current.YourPluginSidebarWidth = width
mu.Unlock()
return Save()
}
Critical Rules
Rule 1: Never Reset Width in View()
WRONG:
func (p *Plugin) View(width, height int) string {
p.sidebarWidth = width * 30 / 100 // BUG: Overwrites drag changes every render!
}
CORRECT: Width is only set when sidebarWidth == 0. All other code paths must not unconditionally overwrite it.
Rule 2: Hit Region X Coordinates
Divider X position = sidebarWidth, NOT sidebarWidth + borderWidth.
When lipgloss renders Width(sidebarWidth), the pane occupies columns 0 to sidebarWidth-1. The divider starts at column sidebarWidth.
Rule 3: Hit Region Priority (Registration Order)
HitMap.Test() checks regions in reverse order - last added = checked first.
The divider region MUST be registered LAST so it takes priority over overlapping pane regions.
// CORRECT ORDER:
p.mouseHandler.HitMap.AddRect(regionSidebar, ...) // Lowest priority
p.mouseHandler.HitMap.AddRect(regionMainPane, ...) // Medium priority
p.mouseHandler.HitMap.AddRect(regionPaneDivider, ...) // HIGHEST priority (last)
Rule 4: Divider Hit Width
Use dividerHitWidth := 3 (wider than the visual 1-character divider) to make clicking easier.
Rule 5: Height for Hit Regions
Use p.height for hit region height, not paneHeight or paneHeight + 2.
Performance Optimization: Hit Region Caching
For plugins with many hit regions, use a dirty flag to avoid rebuilding every render:
type Plugin struct {
hitRegionsDirty bool
prevWidth int
prevHeight int
prevScrollOff int
}
func (p *Plugin) renderTwoPane() string {
if p.width != p.prevWidth || p.height != p.prevHeight {
p.hitRegionsDirty = true
p.prevWidth = p.width
p.prevHeight = p.height
}
if p.scrollOffset != p.prevScrollOff {
p.hitRegionsDirty = true
p.prevScrollOff = p.scrollOffset
}
// ... render content ...
if p.hitRegionsDirty {
p.mouseHandler.HitMap.Clear()
// Register all hit regions...
p.hitRegionsDirty = false
}
return content
}
Also mark hitRegionsDirty = true when:
- View mode changes (toggling list/detail)
- Content changes (items loaded, expanded/collapsed)
- Sidebar visibility toggles
See internal/plugins/conversations/view_layout.go and plugin_input.go for a complete implementation.
Debugging
If drag is not working, add temporary logging:
func (p *Plugin) handleMouseClick(action mouse.MouseAction) (*Plugin, tea.Cmd) {
log.Printf("CLICK x=%d y=%d region=%v", action.X, action.Y, action.Region)
}
Common issues:
- Region is nil or wrong pane: Check X coordinate calculation and registration order
- Drag starts but width does not change: Check that
handleMouseDragis being called - Width resets after drag: Search for code that sets
sidebarWidthunconditionally
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
create-prompt
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.
merge-strategy
Git merge strategies, conflict resolution approaches, merge vs rebase recommendations, and branch integration patterns in sidecar. Covers pull strategy menu, direct merge workflow, squash merge, commit message templates, configurable defaults, and protected branches. Use when working on git merge features or making decisions about merge strategies.
keyboard-shortcuts
Reference for keyboard shortcut implementation, keybinding registration, shortcut parity with vim and other TUI tools, and the complete shortcut assignment table across all sidecar plugins. Use when adding or modifying keyboard shortcuts, checking shortcut assignments, resolving key conflicts, or assessing alignment with vim conventions.
profile-memory
Profile memory usage in sidecar using Go pprof, system tools, and heap analysis. Covers identifying memory leaks, goroutine leaks, file descriptor accumulation, and CPU profiling. Use when investigating memory issues, profiling performance, debugging memory leaks, or diagnosing unresponsive plugins.
create-theme
Create custom color themes for Sidecar, including base theme selection, color overrides, gradient borders, tab styles, per-project themes, community themes, and programmatic theme registration. Use when creating or modifying themes, adjusting UI appearance, or debugging color/style issues. See references/palette-reference.md for the full color palette with all keys and per-theme values.
feature-flags
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.
Didn't find tool you were looking for?