/create-modal
Create declarative modals using the modal library API. Covers modal types (confirm, input, select, form), sections (Text, Buttons, Input, Textarea, Checkbox, List, When, Custom), rendering with OverlayModal, and keyboard/mouse handling. Use when adding modals or dialogs to the
$ npx -y skills add marcus/sidecar --skill create-modal --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/create-modal
Context preview
The summary Claude sees to decide when to auto-load this skill.
Create declarative modals using the modal library API. Covers modal types (confirm, input, select, form), sections (Text, Buttons, Input, Textarea, Checkbox, List, When, Custom), rendering with OverlayModal, and keyboard/mouse handling. Use when adding modals or dialogs to the
SKILL.md
create-modal.SKILL.mdname: create-modal
description: Create declarative modals using the modal library API. Covers modal types (confirm, input, select, form), sections (Text, Buttons, Input, Textarea, Checkbox, List, When, Custom), rendering with OverlayModal, and keyboard/mouse handling. Use when adding modals or dialogs to the application.
Creating Declarative Modals
Use the `internal/modal` package. The library handles keyboard navigation, mouse hit regions, hover states, and scrolling automatically.
Quick Start
import "github.com/marcus/sidecar/internal/modal"
// 1. Create the 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"),
))
// 2. Render in View
func (p *Plugin) View(width, height int) string {
background := p.renderListView(width, height)
rendered := p.myModal.Render(width, height, p.mouseHandler)
return ui.OverlayModal(background, rendered, width, height)
}
// 3. Handle input in Update
case tea.KeyMsg:
action, cmd := p.myModal.HandleKey(msg)
if action != "" {
return p.handleAction(action) // "delete", "cancel", etc.
}
return p, cmd
case tea.MouseMsg:
action := p.myModal.HandleMouse(msg, p.mouseHandler)
if action != "" {
return p.handleAction(action)
}
return p, nilCritical: Modal Initialization Pattern
The modal must exist before input handling. Create an `ensure` function called in **both** View and Update:
func (p *Plugin) ensureMyModal() {
if p.targetItem == nil {
return // Required state missing
}
modalW := 50
if modalW > p.width-4 {
modalW = p.width - 4
}
if modalW < 20 {
modalW = 20
}
// Only rebuild if needed
if p.myModal != nil && p.myModalWidthCache == modalW {
return
}
p.myModalWidthCache = modalW
p.myModal = modal.New("Title", modal.WithWidth(modalW), ...).
AddSection(...)
}**Call `ensureModal()` before the nil check in key handlers:**
func (p *Plugin) handleMyModalKeys(msg tea.KeyMsg) tea.Cmd {
p.ensureMyModal() // CRITICAL: Before nil check
if p.myModal == nil {
return nil
}
action, cmd := p.myModal.HandleKey(msg)
// ...
}Without this, the first keypress after opening drops because View runs after Update in bubbletea.
Constructor and Options
m := modal.New(title string, opts ...Option)
| Option | Description | Default | |--------|-------------|---------| | `WithWidth(int)` | Modal width in characters | 50 | | `WithVariant(Variant)` | Visual style | `VariantDefault` | | `WithPrimaryAction(string)` | Action ID for Enter on inputs | "" | | `WithHints(bool)` | Show "Tab to switch..." hint | true | | `WithCloseOnBackdropClick(bool)` | Backdrop click returns "cancel" | true |
**Variants:** `VariantDefault`, `VariantDanger` (red), `VariantWarning` (yellow), `VariantInfo` (blue)
Built-in Sections
Text and Spacer
modal.Text("Static text with auto line wrapping")
modal.Spacer() // Single blank lineButtons
modal.Buttons(
modal.Btn(" Save ", "save"), // Standard button
modal.Btn(" Delete ", "delete", modal.BtnDanger()), // Red
modal.Btn(" Submit ", "submit", modal.BtnPrimary()), // Primary
modal.Btn(" Cancel ", "cancel"),
)- Include padding in labels: `" Save "` not `"Save"`
- Button IDs are returned as actions
- Tab/Shift+Tab cycles focus
Input
var nameInput textinput.Model
modal.Input("name-input", &nameInput)
modal.InputWithLabel("name-input", "Name:", &nameInput)
modal.Input("name-input", &nameInput,
modal.WithSubmitOnEnter(true), // Default: true
modal.WithSubmitAction("submit"), // Override primary action
)Textarea
var msgArea textarea.Model
modal.Textarea("message", &msgArea, 5) // height in lines
modal.TextareaWithLabel("message", "Label:", &msgArea, 5)- Enter inserts newlines (never submits)
Checkbox
var includeFiles bool
modal.Checkbox("include-files", "Include untracked files", &includeFiles)- Enter or Space toggles
List
items := []modal.ListItem{
{ID: "item-1", Label: "First item", Data: someValue},
{ID: "item-2", Label: "Second item"},
}
var selectedIdx int
modal.List("my-list", items, &selectedIdx, modal.WithMaxVisible(5))- j/k or up/down moves selection; Enter returns selected item's ID
When (Conditional)
modal.When(func() bool { return showWarning },
modal.Text("Warning: This action is irreversible!"),
)Custom
modal.Custom(
func(contentWidth int, focusID, hoverID string) modal.RenderedSection {
return modal.RenderedSection{
Content: content,
Focusables: []modal.FocusableInfo{
{ID: "custom-btn", OffsetX: 0, OffsetY: 2, Width: 10, Height: 1},
},
}
},
func(msg tea.Msg, focusID string) (string, tea.Cmd) {
return "", nil // can be nil if no custom input handling
},
)Handling Input
Keyboard
action, cmd := m.HandleKey(msg)
| Key | Behavior | |-----|----------| | Tab | Focus next element | | Shift+Tab | Focus previous element | | Enter | Return focused element's ID (or primaryAction for inputs) | | Esc | Return "cancel" | | Other | Forwarded to focused section |
Mouse
action := m.HandleMouse(msg, p.mouseHandler)
| Event | Behavior | |-------|----------| | Click backdrop | Return "cancel" (if enabled) | | Click button/checkbox | Return element ID | | Hover element | Update hover state | | Scroll on modal | Scroll content |
Modal Methods
m.FocusedID() string // Currently focused element
Read more
name: create-modal description: Create declarative modals using the modal library API. Covers modal types (confirm, input, select, form), sections (Text, Buttons, Input, Textarea, Checkbox, List, When, Custom), rendering with OverlayModal, and keyboard/mouse handling. Use when adding modals or dialogs to the application.
Creating Declarative Modals
Use the `internal/modal` package. The library handles keyboard navigation, mouse hit regions, hover states, and scrolling automatically.
Quick Start
import "github.com/marcus/sidecar/internal/modal"
// 1. Create the 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"),
))
// 2. Render in View
func (p *Plugin) View(width, height int) string {
background := p.renderListView(width, height)
rendered := p.myModal.Render(width, height, p.mouseHandler)
return ui.OverlayModal(background, rendered, width, height)
}
// 3. Handle input in Update
case tea.KeyMsg:
action, cmd := p.myModal.HandleKey(msg)
if action != "" {
return p.handleAction(action) // "delete", "cancel", etc.
}
return p, cmd
case tea.MouseMsg:
action := p.myModal.HandleMouse(msg, p.mouseHandler)
if action != "" {
return p.handleAction(action)
}
return p, nilCritical: Modal Initialization Pattern
The modal must exist before input handling. Create an `ensure` function called in **both** View and Update:
func (p *Plugin) ensureMyModal() {
if p.targetItem == nil {
return // Required state missing
}
modalW := 50
if modalW > p.width-4 {
modalW = p.width - 4
}
if modalW < 20 {
modalW = 20
}
// Only rebuild if needed
if p.myModal != nil && p.myModalWidthCache == modalW {
return
}
p.myModalWidthCache = modalW
p.myModal = modal.New("Title", modal.WithWidth(modalW), ...).
AddSection(...)
}**Call `ensureModal()` before the nil check in key handlers:**
func (p *Plugin) handleMyModalKeys(msg tea.KeyMsg) tea.Cmd {
p.ensureMyModal() // CRITICAL: Before nil check
if p.myModal == nil {
return nil
}
action, cmd := p.myModal.HandleKey(msg)
// ...
}Without this, the first keypress after opening drops because View runs after Update in bubbletea.
Constructor and Options
m := modal.New(title string, opts ...Option)
| Option | Description | Default | |--------|-------------|---------| | `WithWidth(int)` | Modal width in characters | 50 | | `WithVariant(Variant)` | Visual style | `VariantDefault` | | `WithPrimaryAction(string)` | Action ID for Enter on inputs | "" | | `WithHints(bool)` | Show "Tab to switch..." hint | true | | `WithCloseOnBackdropClick(bool)` | Backdrop click returns "cancel" | true |
**Variants:** `VariantDefault`, `VariantDanger` (red), `VariantWarning` (yellow), `VariantInfo` (blue)
Built-in Sections
Text and Spacer
modal.Text("Static text with auto line wrapping")
modal.Spacer() // Single blank lineButtons
modal.Buttons(
modal.Btn(" Save ", "save"), // Standard button
modal.Btn(" Delete ", "delete", modal.BtnDanger()), // Red
modal.Btn(" Submit ", "submit", modal.BtnPrimary()), // Primary
modal.Btn(" Cancel ", "cancel"),
)- Include padding in labels: `" Save "` not `"Save"`
- Button IDs are returned as actions
- Tab/Shift+Tab cycles focus
Input
var nameInput textinput.Model
modal.Input("name-input", &nameInput)
modal.InputWithLabel("name-input", "Name:", &nameInput)
modal.Input("name-input", &nameInput,
modal.WithSubmitOnEnter(true), // Default: true
modal.WithSubmitAction("submit"), // Override primary action
)Textarea
var msgArea textarea.Model
modal.Textarea("message", &msgArea, 5) // height in lines
modal.TextareaWithLabel("message", "Label:", &msgArea, 5)- Enter inserts newlines (never submits)
Checkbox
var includeFiles bool
modal.Checkbox("include-files", "Include untracked files", &includeFiles)- Enter or Space toggles
List
items := []modal.ListItem{
{ID: "item-1", Label: "First item", Data: someValue},
{ID: "item-2", Label: "Second item"},
}
var selectedIdx int
modal.List("my-list", items, &selectedIdx, modal.WithMaxVisible(5))- j/k or up/down moves selection; Enter returns selected item's ID
When (Conditional)
modal.When(func() bool { return showWarning },
modal.Text("Warning: This action is irreversible!"),
)Custom
modal.Custom(
func(contentWidth int, focusID, hoverID string) modal.RenderedSection {
return modal.RenderedSection{
Content: content,
Focusables: []modal.FocusableInfo{
{ID: "custom-btn", OffsetX: 0, OffsetY: 2, Width: 10, Height: 1},
},
}
},
func(msg tea.Msg, focusID string) (string, tea.Cmd) {
return "", nil // can be nil if no custom input handling
},
)Handling Input
Keyboard
action, cmd := m.HandleKey(msg)
| Key | Behavior | |-----|----------| | Tab | Focus next element | | Shift+Tab | Focus previous element | | Enter | Return focused element's ID (or primaryAction for inputs) | | Esc | Return "cancel" | | Other | Forwarded to focused section |
Mouse
action := m.HandleMouse(msg, p.mouseHandler)
| Event | Behavior | |-------|----------| | Click backdrop | Return "cancel" (if enabled) | | Click button/checkbox | Return element ID | | Hover element | Update hover state | | Scroll on modal | Scroll content |
Modal Methods
m.FocusedID() string // Currently focused element
You might never open your editor again. Status: Ready for daily use. Please report any issues you encounter.
Other skills on sidecar.
- /create-adapter
Create conversation adapters for importing AI chat history from different tools (Claude Code, Cursor, Warp, Codex, etc.). Covers the adapter.Adapter interface, caching strategies, incremental parsing, watch/FD management, and performance standards. Use when creating a new
Open skill - /create-plugin
Create new sidecar plugins implementing the plugin.Plugin interface, rendering views with Bubble Tea, handling keyboard input via keymap contexts, and integrating with the app shell (footer hints, event bus, adapters). Use when creating a new plugin, modifying plugin
Open skill - /create-prompt
Create prompts for sidecar workspaces. Covers prompt structure (name, ticketMode, body), template variables (ticket with fallbacks), config file locations (global vs project), and scope overrides. Use when creating or modifying prompts in sidecar config files.
Open skill - /create-theme
Create custom color themes for Sidecar, including base theme selection, color overrides, gradient borders, tab styles, per-project themes, community themes, and programmatic theme registration. Use when creating or modifying themes, adjusting UI appearance, or debugging
Open skill - /drag-pane
Drag-and-drop pane resizing implementation for two-pane plugin layouts. Covers mouse event handling via the internal/mouse package, hit region registration, drag delta calculation, width clamping, state persistence, and pane layout management. Use when working on pane resizing,
Open skill - /feature-flags
Creating and using feature flags in sidecar for gating experimental functionality. Covers flag registration, checking flags in code, config file and CLI overrides, and priority resolution. Use when adding feature flags, toggling features, or gating new functionality behind flags.
Open skill

