AgentSkillsCN

ui-features

在侧边栏中实现UI/UX功能,包括模态窗口(内置/模态库)、键盘快捷键、鼠标支持、滚动功能、药丸/标签渲染,以及窗格大小调整。适用于实现UI功能、处理用户输入、添加键盘快捷键、构建模态窗口,或进行UX优化的场景。

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 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/declarative-modal-guide.md for the full API.

Create a modal

go
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

go
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

go
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
go
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:

go
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:

go
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)
go
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).

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.

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., "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"}

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.

go
func (p *Plugin) ConsumesTextInput() bool {
    return p.showMyModal
}

Footer rendering flow

code
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 q behavior with isRootContext()

Core files

FilePurpose
internal/plugin/plugin.goCommand struct, Commands(), FocusContext(), TextInputConsumer
internal/keymap/bindings.goDefault key-to-command mappings
internal/keymap/registry.goRuntime binding lookup
internal/app/update.goKey routing, isRootContext()
internal/app/view.goFooter rendering

Scrollbar (internal/ui)

go
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

go
type Plugin struct {
    mouseHandler *mouse.Handler
}
func New() *Plugin {
    return &Plugin{mouseHandler: mouse.NewHandler()}
}

Register hit regions during render

go
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

SymptomFix
Clicks don't registerCheck region order (pane first)
Y offsets wrongAccount for borders, padding, headers
Scroll over items brokenInclude item regions in scroll routing
Double-click failsEnsure consistent region ID/bounds
Drag brokenCall StartDrag on click, check DragRegion during drag