Skip to content
Development
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

From plugin
sidecar
1k17 skills
Install
$ npx -y skills add marcus/sidecar --skill ui-features --agent claude-code

How 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.md
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, nil

Modal 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, nil

Modal 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
Ships withsidecar

You might never open your editor again. Status: Ready for daily use. Please report any issues you encounter.

Get the whole plugin
Stats
1,044
Stars
79
Forks
Active
Maintenance
Go
Language
MIT
License
24m ago
Last commit
7mo ago
Created

Repo: marcus/sidecar

Other skills on sidecar.