/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
$ npx -y skills add marcus/sidecar --skill ui-features --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/ui-features
Context preview
The summary Claude sees to decide when to auto-load this skill.
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
SKILL.md
ui-features.SKILL.mdname: ui-features
description: 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.
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 with `ui.OverlayModal`, avoid manual hit region math
- Pills/chips/tabs: use `styles.RenderPillWithStyle`; auto-fallback when `nerdFontsEnabled` is 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/deprecated/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, nilModal initialization and caching (critical)
Always call `ensureModal()` in BOTH View and Update handlers. Create an ensure function that: 1. Returns early if required state is missing 2. Caches based on width to avoid rebuilding every frame 3. 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, nilModal keyboard shortcuts and footer hints
Modals need their own focus context and commands for footer hints:
1. Return a dedicated context from `FocusContext()` 2. Add commands for the modal context in `Commands()` 3. Add bindings in `internal/keymap/bindings.go` 4. 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`/`HandleMouse` handle 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.Custom` and 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 with `lipgloss.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
1. **Command ID** in `Commands()` (e.g., `"stage-file"`) 2. **Binding command** in `internal/keymap/bindings.go` (e.g., `"stage-file"`) 3. **Context string** in both places (e.g., `
Read more
name: ui-features description: 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.
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 with `ui.OverlayModal`, avoid manual hit region math
- Pills/chips/tabs: use `styles.RenderPillWithStyle`; auto-fallback when `nerdFontsEnabled` is 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/deprecated/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, nilModal initialization and caching (critical)
Always call `ensureModal()` in BOTH View and Update handlers. Create an ensure function that: 1. Returns early if required state is missing 2. Caches based on width to avoid rebuilding every frame 3. 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, nilModal keyboard shortcuts and footer hints
Modals need their own focus context and commands for footer hints:
1. Return a dedicated context from `FocusContext()` 2. Add commands for the modal context in `Commands()` 3. Add bindings in `internal/keymap/bindings.go` 4. 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`/`HandleMouse` handle 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.Custom` and 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 with `lipgloss.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
1. **Command ID** in `Commands()` (e.g., `"stage-file"`) 2. **Binding command** in `internal/keymap/bindings.go` (e.g., `"stage-file"`) 3. **Context string** in both places (e.g., `
You might never open your editor again. Status: Ready for daily use. Please report any issues you encounter.
Other skills on sidecar.
- /create-adapter
Create conversation adapters for importing AI chat history from different tools (Claude Code, Cursor, Warp, Codex, etc.). Covers the adapter.Adapter interface, caching strategies, incremental parsing, watch/FD management, and performance standards. Use when creating a new
Open skill - /create-modal
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
Open skill - /create-plugin
Create new sidecar plugins implementing the plugin.Plugin interface, rendering views with Bubble Tea, handling keyboard input via keymap contexts, and integrating with the app shell (footer hints, event bus, adapters). Use when creating a new plugin, modifying plugin
Open skill - /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.
Open skill - /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
Open 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,
Open skill

