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 |