Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/marcus-ui-features/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 29% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 13% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 34% | 0% |
| case-01 | ✗→✓ | ▲ Improved | 5% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 36% | 0% |
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.
internal/modal, render with ui.OverlayModal, avoid manual hit region mathstyles.RenderPillWithStyle; auto-fallback when nerdFontsEnabled is falsecontentHeight := height - headerLines - footerLinesCommands()All new modals must use internal/modal. See docs/guides/deprecated/declarative-modal-guide.md for the full API.
gom := 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"), ))
gofunc (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) }
gocase 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
Always call ensureModal() in BOTH View and Update handlers. Create an ensure function that:
gofunc (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:
gofunc (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 }
When modal content depends on async data, invalidate the cache when data arrives:
gocase MyDataLoadedMsg: p.myData = msg.Data p.clearMyModal() // Force rebuild with new content return p, nil
Modals need their own focus context and commands for footer hints:
FocusContext()Commands()internal/keymap/bindings.gomodal.HandleKey (Tab/Enter/Esc are handled internally)gofunc (p *Plugin) FocusContext() string { switch p.viewMode { case ViewModeError: return "git-error" case ViewModePushMenu: return "git-push-menu" default: return "git-status" } }
HandleKey/HandleMouse handle Tab, Shift+Tab, Enter, Esc internallyWithCloseOnBackdropClick(false) to disablemodal.Custom and return explicit focusable offsetsSetFocus(id) auto-scrolls viewport to focused elementui.OverlayModal(background, modal, width, height) for dimmed overlays; do not pre-center with lipgloss.PlaceLipgloss 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.
Controlled by nerdFontsEnabled in ~/.config/sidecar/config.json (ui.nerdFontsEnabled).
go// 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.
For complete per-plugin shortcut listings, see references/keyboard-shortcuts-reference.md.
Commands() (e.g., "stage-file")internal/keymap/bindings.go (e.g., "stage-file")"git-status")go// 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"}
Return different context strings from FocusContext() for different modes. Each context gets its own footer hints and key bindings.
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.
When a view has text input, implement plugin.TextInputConsumer and return true while active. This prevents app-level shortcuts from intercepting typed characters.
gofunc (p *Plugin) ConsumesTextInput() bool { return p.showMyModal }
footerHints()
+-- pluginFooterHints() -> Commands() filtered by FocusContext(), sorted by Priority
+-- globalFooterHints() -> App-level hints
renderHintLineTruncated(hints, availableWidth)
-> Renders left-to-right until width exceededCommands() with ID, Name, Context, PriorityFocusContext() returns matching contextinternal/keymap/bindings.goUpdate() if app does not interceptq behavior with isRootContext()| 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 |
goui.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.
gotype Plugin struct { mouseHandler *mouse.Handler } func New() *Plugin { return &Plugin{mouseHandler: mouse.NewHandler()} }
gofunc (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 }
Regions tested in reverse order. Add general regions first, specific regions last.
App offsets Y by 2 (header height) before forwarding to plugins. Plugins operate in local coords where Y=0 is plugin content top.
| 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 |
Other measured skills in the registry, with their headline benchmark lift.