Agent skill
ui-features
Implementing UI/UX features in sidecar including modals (internal/modal library), keyboard shortcuts, mouse support, scrolling, pill/tab rendering, and pane resizing. Use when implementing UI features, handling user input, adding keyboard shortcuts, building modals, or working on UX improvements.
Install this agent skill to your Project
npx add-skill https://github.com/marcus/sidecar/tree/main/.claude/skills/ui-features
SKILL.md
UI Feature Implementation
Single entry point for sidecar UI work. All new modals must use internal/modal. For complete keyboard shortcut listings, see references/keyboard-shortcuts-reference.md.
Quick Checklist
- Modals: use
internal/modal, render withui.OverlayModal, avoid manual hit region math - Pills/chips/tabs: use
styles.RenderPillWithStyle; auto-fallback whennerdFontsEnabledis false - Keyboard: Commands + FocusContext + bindings must match; names short; priorities set
- Mouse: rebuild hit regions on each render; add general regions first, specific last
- Rendering: keep output within View width/height to avoid header/footer overlap. Use
contentHeight := height - headerLines - footerLines - Testing: verify keyboard, mouse, hover, scrolling, and footer hints
- Plugins must NOT render their own footer -- the app renders a unified footer from
Commands()
Modals (internal/modal)
All new modals must use internal/modal. See docs/guides/declarative-modal-guide.md for the full API.
Create a modal
m := modal.New("Delete Worktree?",
modal.WithWidth(58),
modal.WithVariant(modal.VariantDanger),
modal.WithPrimaryAction("delete"),
).
AddSection(modal.Text("Name: " + wt.Name)).
AddSection(modal.Spacer()).
AddSection(modal.Buttons(
modal.Btn(" Delete ", "delete", modal.BtnDanger()),
modal.Btn(" Cancel ", "cancel"),
))
Render in View
func (p *Plugin) renderDeleteView(width, height int) string {
background := p.renderListView(width, height)
rendered := p.deleteModal.Render(width, height, p.mouseHandler)
return ui.OverlayModal(background, rendered, width, height)
}
Handle input in Update
case tea.KeyMsg:
action, cmd := p.deleteModal.HandleKey(msg)
if action != "" {
return p.handleModalAction(action)
}
return p, cmd
case tea.MouseMsg:
action := p.deleteModal.HandleMouse(msg, p.mouseHandler)
if action != "" {
return p.handleModalAction(action)
}
return p, nil
Modal initialization and caching (critical)
Always call ensureModal() in BOTH View and Update handlers. Create an ensure function that:
- Returns early if required state is missing
- Caches based on width to avoid rebuilding every frame
- Creates the modal only when needed
func (p *Plugin) ensureMyModal() {
if p.targetItem == nil {
return
}
modalW := 50
if modalW > p.width-4 { modalW = p.width - 4 }
if modalW < 20 { modalW = 20 }
if p.myModal != nil && p.myModalWidthCache == modalW {
return
}
p.myModalWidthCache = modalW
p.myModal = modal.New("Title", modal.WithWidth(modalW), ...).
AddSection(...)
}
The key handler MUST call ensure before checking nil:
func (p *Plugin) handleMyModalKeys(msg tea.KeyMsg) tea.Cmd {
p.ensureMyModal() // CRITICAL: Initialize before nil check
if p.myModal == nil { return nil }
action, cmd := p.myModal.HandleKey(msg)
return cmd
}
Async content invalidation
When modal content depends on async data, invalidate the cache when data arrives:
case MyDataLoadedMsg:
p.myData = msg.Data
p.clearMyModal() // Force rebuild with new content
return p, nil
Modal keyboard shortcuts and footer hints
Modals need their own focus context and commands for footer hints:
- Return a dedicated context from
FocusContext() - Add commands for the modal context in
Commands() - Add bindings in
internal/keymap/bindings.go - Intercept custom keys before
modal.HandleKey(Tab/Enter/Esc are handled internally)
func (p *Plugin) FocusContext() string {
switch p.viewMode {
case ViewModeError: return "git-error"
case ViewModePushMenu: return "git-push-menu"
default: return "git-status"
}
}
Modal notes
HandleKey/HandleMousehandle Tab, Shift+Tab, Enter, Esc internally- Backdrop clicks return "cancel"; use
WithCloseOnBackdropClick(false)to disable - Use built-in sections (Text, Input, Textarea, Buttons, Checkbox, List, When) before custom layouts
- For bespoke layouts, use
modal.Customand return explicit focusable offsets SetFocus(id)auto-scrolls viewport to focused element- Prefer
ui.OverlayModal(background, modal, width, height)for dimmed overlays; do not pre-center withlipgloss.Place
Background colors (critical)
Lipgloss Background() does not cascade into child content. ANSI resets clear the parent background. Solution: replace ANSI resets within viewport lines with reset + background re-apply, then pad short lines. See fillBackground in internal/modal/layout.go.
Pill-Shaped Elements (internal/styles)
Controlled by nerdFontsEnabled in ~/.config/sidecar/config.json (ui.nerdFontsEnabled).
// With explicit colors
label := styles.RenderPill("Output", styles.TextPrimary, styles.Primary, "")
// With a lipgloss.Style (preferred for tabs/chips)
active := styles.RenderPillWithStyle("Output", styles.BarChipActive, "")
inactive := styles.RenderPillWithStyle("Diff", styles.BarChip, "")
Available styles: styles.BarChip (inactive), styles.BarChipActive (active), or custom lipgloss.Style.
Test with both nerdFontsEnabled: true and false to verify fallback.
Keyboard Shortcuts
For complete per-plugin shortcut listings, see references/keyboard-shortcuts-reference.md.
Three things must match
- Command ID in
Commands()(e.g.,"stage-file") - Binding command in
internal/keymap/bindings.go(e.g.,"stage-file") - Context string in both places (e.g.,
"git-status")
// 1) Commands()
{ID: "stage-file", Name: "Stage", Context: "git-status", Priority: 1}
// 2) FocusContext()
func (p *Plugin) FocusContext() string { return "git-status" }
// 3) bindings.go
{Key: "s", Command: "stage-file", Context: "git-status"}
Multiple contexts (view modes)
Return different context strings from FocusContext() for different modes. Each context gets its own footer hints and key bindings.
Priority guidelines
- 1: Primary actions (Stage, Commit, Open)
- 2: Secondary actions (Diff, Search, Push)
- 3: Tertiary actions (History, Refresh)
- 4+: Palette only
Root contexts (q behavior)
In root contexts, q shows quit confirmation. In non-root, q navigates back. Root contexts: global, conversations, conversations-sidebar, git-status, git-status-commits, git-status-diff, file-browser-tree, workspace-list, td-monitor.
Update isRootContext() in internal/app/update.go when adding new contexts.
Text input contexts
When a view has text input, implement plugin.TextInputConsumer and return true while active. This prevents app-level shortcuts from intercepting typed characters.
func (p *Plugin) ConsumesTextInput() bool {
return p.showMyModal
}
Footer rendering flow
footerHints()
+-- pluginFooterHints() -> Commands() filtered by FocusContext(), sorted by Priority
+-- globalFooterHints() -> App-level hints
renderHintLineTruncated(hints, availableWidth)
-> Renders left-to-right until width exceeded
Keyboard checklist
- Command in
Commands()with ID, Name, Context, Priority FocusContext()returns matching context- Binding in
internal/keymap/bindings.go - Key handled in
Update()if app does not intercept - No conflicting keys in same context
- Short footer hint names, primary actions Priority 1-2
- Verify
qbehavior withisRootContext()
Core files
| File | Purpose |
|---|---|
internal/plugin/plugin.go |
Command struct, Commands(), FocusContext(), TextInputConsumer |
internal/keymap/bindings.go |
Default key-to-command mappings |
internal/keymap/registry.go |
Runtime binding lookup |
internal/app/update.go |
Key routing, isRootContext() |
internal/app/view.go |
Footer rendering |
Scrollbar (internal/ui)
ui.RenderScrollbar(ui.ScrollbarParams{
TotalItems: len(items),
ScrollOffset: p.scrollOffset,
VisibleItems: visibleCount,
TrackHeight: height,
})
Pattern: reduce content width by 1, render content, render scrollbar, join horizontally with lipgloss.JoinHorizontal(lipgloss.Top, content, scrollbar).
For multi-line items, set TrackHeight to actual terminal rows: visibleCount * linesPerItem.
Mouse Support
Setup
type Plugin struct {
mouseHandler *mouse.Handler
}
func New() *Plugin {
return &Plugin{mouseHandler: mouse.NewHandler()}
}
Register hit regions during render
func (p *Plugin) View(width, height int) string {
p.mouseHandler.Clear()
p.mouseHandler.HitMap.AddRect("pane", 0, 0, width, height, nil)
p.mouseHandler.HitMap.AddRect("item", 2, 5, width-4, 1, 0)
return content
}
Region ordering (critical)
Regions tested in reverse order. Add general regions first, specific regions last.
Coordinate system
App offsets Y by 2 (header height) before forwarding to plugins. Plugins operate in local coords where Y=0 is plugin content top.
Common patterns
- Click to select/focus, scroll wheel to move, double-click to open
- Drag regions for pane resizing
- Hover for visual feedback (focus takes precedence)
Mouse troubleshooting
| Symptom | Fix |
|---|---|
| Clicks don't register | Check region order (pane first) |
| Y offsets wrong | Account for borders, padding, headers |
| Scroll over items broken | Include item regions in scroll routing |
| Double-click fails | Ensure consistent region ID/bounds |
| Drag broken | Call StartDrag on click, check DragRegion during drag |
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?