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.
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.
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
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")
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.
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.
Wheel boundaries (required for every new scrollable surface)
Trackpad and Magic Mouse flicks emit hundreds of inertial wheel events. Bubble Tea
repaints all of Sidecar after every accepted one, so clamping an offset during
Update is too late — the freeze happens before the clamp helps. tea.WithFilter(app.FilterInput)
asks one read-only question beforeUpdate and View:
Would this exact wheel event change the surface currently under the pointer?
Rule: every new scrollable surface or modal must provide exact pre-update
bounds, or explicitly declare why its answer is unknown. "Unknown" is a valid,
safe answer — guessing is not. Return true only when the event is a certain
no-op.
How to comply:
Implement plugin.WheelBoundaryConsumer on the plugin
(WheelAtBoundary(tea.MouseWheelMsg) bool) and add
var _ plugin.WheelBoundaryConsumer = (*Plugin)(nil).
Mirror handleMouseScroll's routing exactly — same hit map, same modal
precedence — but load nothing, move nothing, render nothing.
Derive the maximum from the same helper the renderer clamps with
(internal/scroll.Bounds), never a second copy of the arithmetic.
Declarative modals answer for themselves via
modal.WheelAtBoundary(msg, handler); the host only owns precedence between
a modal, a nested overlay, and a custom scrolling child.
Call Invalidate() when content or geometry changes so a stale layout
answers unknown instead of wrong.
Return false (unknown) for: embedded models you do not own, tmux panes with
mouse reporting, scrollback with unloaded history, lazy lists that can load
more, and anything before its first trustworthy render.
Declare the surface's policy in assembly.WheelBoundaryRegistry
(covered / externally-owned / deprecated-exclusion). A new plugin
without a row fails the assembly tests; a new ModalKind without a row fails
TestEveryModalKindHasALedgerRow in internal/app.
Prove it with the shared stress fixture internal/scroll/scrolltest:
scrolltest.Run(t, scrolltest.Tail{...}) feeds hundreds of same-direction
events and one reverse event, with no sleeps.
Regions tested in reverse order. Add general regions first, specific regions last.
Coordinate system
App offsets Y by headerHeight (the single painted header row) 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