ui-features
GitHub提供侧边栏UI功能实现指南,涵盖模态框、快捷键、鼠标交互及渲染规范。指导开发者使用内部库构建符合标准的界面组件与交互逻辑。
Trigger Scenarios
Install
npx skills add marcus/sidecar --skill ui-features -g -y
SKILL.md
Frontmatter
{
"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 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/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:
- 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)
rendered, geom := ui.RenderScrollbarWithState(ui.ScrollbarParams{
TotalItems: len(items),
ScrollOffset: p.scrollOffset,
VisibleItems: visibleCount,
TrackHeight: height,
}, style) // ui.ScrollbarStyle{Thumb: state, Track: state}; zero value renders byte-identically to RenderScrollbar
Every scrollbar backed by real scroll state is interactive under the mouse (macOS-style),
including the terminal panes' bars over captured scrollback. Terminal surfaces map
presses and drags through tty.WindowScrollbarFor onto the shared window model: the
gesture freezes the window at an absolute start, release thaws (offset zero follows
again), and history loads defer to release so a mid-gesture renumber cannot shift the
mapping. Adoption ledger:
docs/plans/active/mouse-draggable-scrollbars.md ("Adoption outcome").
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 contract per surface:
- Register
ui.RegionScrollbarTrack, thenui.RegionScrollbarThumb, after content regions in the same render pass that builds the HitMap (reverse scan gives the bar its column). Register nothing whengeom.HasThumbis false — that column is a spacer. - Thumb press = grab at that row; track press = jump-to-spot anchored at the grabbed row;
both end in
StartDragso releasing anywhere settles cleanly. Drag maps throughui.OffsetAtRowagainst the press-time params snapshot; past-end clamps never end the gesture. - A rapid second press arrives as
ActionDoubleClick: answer it likeActionClick(re-grab), never swallow it. - Emphasis derives via
ui.HandleStateFrom(hovering, dragging)— intensity modulation on the existing theme keys, no new keys.
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 before Update 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.WheelBoundaryConsumeron the plugin (WheelAtBoundary(tea.MouseWheelMsg) bool) and addvar _ 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. CallInvalidate()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 newModalKindwithout a row failsTestEveryModalKindHasALedgerRowininternal/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.
Background: docs/plans/active/scroll-inertia-complete-coverage.md.
Wheel burst coalescing (required for every new scrollable surface)
Boundary drop alone is half the protection. A flick that never reaches a boundary
— mid-range inertia over a long card — still delivers one event per notch, and
each accepted one is an Update + View repaint. tty.WheelBurst /
tty.WheelBursts (internal/tty/wheel.go) coalesce a flick into whole flushes:
"how much of this burst has the surface earned" lives in that one place, so a
flick travels the same distance on every surface.
Rule: every new scrollable content area applies wheel deltas through the shared burst guard and drops at boundaries — both, not either.
How to comply:
- Hold one
tty.WheelBurstper scroll surface. A host with one surface embeds the value in whatever struct owns it (internal/overview/preview.go, workspaceissuePane); a host with several usestty.WheelBurstskeyed per leaf/region (Files tree + preview, app content deck). - Apply through
Add(delta, now); whenokis false the event was held, change nothing, and return early. The held delta rides the next flush — never applydeltadirectly. - Inject the clock for tests: a
wheelNow func() time.Timefield (Files), the plugin clock (p.now()), or a Model hook (notificationCentreNow). Tests drive a whole flick without sleeping — seeinternal/plugins/filebrowser/scroll_burst_test.goand the issue-viewer equivalents beside each host. - Reset the burst wherever the boundary answer is true, so inertia dropped at
the top or bottom cannot spend itself into the next gesture. Crossing
surfaces with
WheelBursts.Forresets automatically. - Keyboard scrolling does not coalesce; do not route keys through the burst.
Reference adoptions: embedded terminals (internal/tty/tty.go), Files tree and
preview (internal/plugins/filebrowser), workspace terminal + issue panes,
overview terminal + issue preview, app content deck + notification centre +
issue preview modal.
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 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 |
Version History
-
10eb061
Current 2026-08-28 11:29
新增交互式终端面板滚动条的实现细节,包括拖拽、跳转及手势冻结逻辑;统一了各组件的滚动条抓取语义。
-
b3ba2b5
2026-08-20 01:12
更新模态框文档路径,新增布局行高与滚动边界策略说明
- 0ddc6b7 2026-07-25 07:17


