원클릭으로
go-bubbletea-patterns
Bubbletea Patterns Skill
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Bubbletea Patterns Skill
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Diagnose Azedarach command latency and daemon/TUI performance with OpenTelemetry traces exported to a local Jaeger backend. Use when investigating slow `az` commands, TUI stalls, daemon RPC latency, missing spans, local Jaeger setup, or trace-driven performance regressions in this repository.
Review and integrate Azedarach issues that are in_review. Use when Codex is asked to run a reviewer/integrator session, sweep active issues that are ready for review, inspect worker evidence and diffs, return actionable findings to live or paused issue sessions, close accepted work, or get in_review issues integrated.
Review, consolidate, test, and harden Azedarach SQLite migrations before integration. Use for any migration, schema ensure/repair logic, trigger or index change, persistence-authority change, migration failure, or pre-merge review of a branch containing database changes. Also use when validating upgrades against real root-user or registered project databases.
Run an iterative code review and fix cycle until findings stabilize. Use when the user asks Codex to review code, review and fix, loop review/fix, address review findings, harden a change before handoff, or keep reviewing until repeated passes find no new actionable issues. Also use proactively before Codex declares coding work done, marks an issue in_review, says a change is ready, or hands off implementation that modified code, tests, build scripts, migrations, config, or developer tooling.
Go Testing Skill
Build and review production-grade Go logging and observability with `log/slog` and OpenTelemetry. Use when adding or refactoring logs, instrumenting request paths, defining event schemas, reducing log cost/cardinality, enforcing redaction/PII policy, correlating logs with traces, or reviewing Go services for observability gaps.
| name | go-bubbletea-patterns |
| description | Bubbletea Patterns Skill |
Version: 1.0 Purpose: Idiomatic Bubbletea patterns learned from Glow, Soft Serve, and community best practices
Bubbletea implements the Elm Architecture:
Key: The program sends messages (tea.Msg), Update handles them, returns (newModel, optionalCmd)
For non-trivial apps, use nested models with a top-level router:
type Model struct {
// Shared state accessible to all sub-models
common *CommonModel
// Sub-models (each implements tea.Model)
board *board.Model
detail *detail.Model
settings *settings.Model
overlays *overlay.Stack
// Current state for routing
state State
}
type CommonModel struct {
config *config.Config
width int
height int
styles *styles.Styles
program *tea.Program // For sending messages from goroutines
}
Key insight from Glow: Share common state via pointer to avoid duplication across sub-models.
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.board.Init(),
m.detail.Init(),
m.settings.Init(),
loadInitialData, // Your custom init command
)
}
Pass ALL messages to relevant sub-models, not just "active" one:
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
// Global handlers first (window size, quit, etc.)
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.common.width = msg.Width
m.common.height = msg.Height
// Propagate to all sub-models
m.board.SetSize(msg.Width, msg.Height)
m.detail.SetSize(msg.Width, msg.Height)
return m, nil
case tea.KeyMsg:
// Handle quit regardless of state
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
}
// Route to active overlay first (if any)
if m.overlays.Current() != nil {
overlay, cmd := m.overlays.Current().Update(msg)
m.overlays.SetCurrent(overlay)
if cmd != nil {
cmds = append(cmds, cmd)
}
// Overlays may consume the message
if m.overlays.Current() != nil {
return m, tea.Batch(cmds...)
}
}
// Route to current view
switch m.state {
case StateBoard:
newBoard, cmd := m.board.Update(msg)
m.board = newBoard.(*board.Model)
cmds = append(cmds, cmd)
case StateDetail:
newDetail, cmd := m.detail.Update(msg)
m.detail = newDetail.(*detail.Model)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
// Command returning a message
func fetchTasks() tea.Cmd {
return func() tea.Msg {
return TasksLoadedMsg{tasks: getTasks()}
}
}
// Command with timeout
func fetchWithTimeout() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return TimeoutMsg{}
})
}
// Command from goroutine
func asyncWork() tea.Cmd {
ch := make(chan tea.Msg)
go func() {
defer close(ch)
ch <- WorkDoneMsg{result: doWork()}
}()
return func() tea.Msg {
return <-ch
}
}
// Batch commands
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.board.Init(),
m.detail.Init(),
tea.Tick(time.Second, tickerTick), // Periodic
)
}
// In goroutine, send via program
func (s *Service) pollState(program *tea.Program) {
for {
time.Sleep(500 * time.Millisecond)
state := s.checkState()
program.Send(StateChangedMsg{state: state})
}
}
// In model Update, handle the message
case StateChangedMsg msg:
// Handle state change
func (m Model) View() string {
switch m.state {
case StateBoard:
return m.board.View()
case StateDetail:
return m.detail.View()
case StateSettings:
return m.settings.View()
default:
return ""
}
}
var (
// Define styles once (avoid reallocation in View())
baseStyle = lipgloss.NewStyle()
titleStyle = baseStyle.Bold(true).Foreground(lipgloss.Color("205"))
activeStyle = baseStyle.Background(lipgloss.Color("240"))
)
func (m Model) View() string {
return lipgloss.NewStyle().
Width(m.common.width).
Height(m.common.height).
Render(
lipgloss.Place(
m.common.width, m.common.height,
lipgloss.Center, lipgloss.Center,
m.content.View(),
),
)
}
var (
boxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("63")).
Padding(1, 2)
)
func (m Model) View() string {
return boxStyle.Render(m.content)
}
type State int
const (
StateBoard State = iota
StateDetail
StateSettings
)
type State struct {
view ViewType
selectedItem int
isLoading bool
}
// Define custom message types (avoid collisions)
type TaskSelectedMsg struct {
id string
}
type TasksLoadedMsg struct {
tasks []Task
}
type StateChangedMsg struct {
state SessionState
}
| Message | Description |
|---|---|
tea.InitMsg | Program started (return initial cmd) |
tea.QuitMsg | Request program exit |
tea.WindowSizeMsg | Terminal size changed |
tea.KeyMsg | Key press event |
tea.MouseMsg | Mouse event (enable with tea.WithMouseCellMotion()) |
tea.TickMsg | Timer event (from tea.Tick) |
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "up", "k":
m.cursor--
case "down", "j":
m.cursor++
case "enter":
return m, m.selectCurrent()
}
}
return m, nil
}
case tea.KeyMsg:
switch msg.Type {
case tea.KeyRunes:
// Any character key (letters, numbers, etc.)
case tea.KeyEnter, tea.KeySpace:
// Enter or Space
case tea.KeyUp, tea.KeyDown, tea.KeyLeft, tea.KeyRight:
// Arrow keys
case tea.KeyCtrlC, tea.KeyCtrlD:
// Ctrl combinations
}
case tea.KeyMsg:
if msg.Alt {
// Alt+key
}
import (
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
)
type Model struct {
list list.Model
input textinput.Model
content viewport.Model
}
func (m Model) Init() tea.Cmd {
m.list = list.New(..., list.NewDefaultDelegate())
m.input = textinput.New()
m.content = viewport.New(0, 0)
return nil
}
type Model struct {
focused focusedComponent
}
type focusedComponent int
const (
focusList focusedComponent = iota
focusInput
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "tab" {
// Cycle focus
m.focused = (m.focused + 1) % 2
}
}
// Update based on focus
switch m.focused {
case focusList:
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
case focusInput:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
}
// ❌ BAD: Reallocates styles every render
func (m Model) View() string {
return lipgloss.NewStyle().
Bold(true).
Render("Title")
}
// ✅ GOOD: Styles defined once
var titleStyle = lipgloss.NewStyle().Bold(true)
func (m Model) View() string {
return titleStyle.Render("Title")
}
// ❌ BAD: Renders entire content every frame
func (m Model) View() string {
return m.content
}
// ✅ GOOD: Only render what changed
func (m Model) View() string {
if m.needsRender {
m.needsRender = false
return m.content
}
return "" // Skip rendering
}
type ConfirmModel struct {
question string
confirmed bool
}
func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "y" {
m.confirmed = true
return m, tea.Quit
}
if msg.String() == "n" || msg.String() == "esc" {
return m, tea.Quit
}
}
return m, nil
}
func (m ConfirmModel) View() string {
return fmt.Sprintf("%s [y/n]", m.question)
}
type Model struct {
loading bool
content string
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case LoadStartMsg:
m.loading = true
case LoadDoneMsg:
m.loading = false
m.content = msg.data
}
return m, nil
}
func (m Model) View() string {
if m.loading {
return "Loading..."
}
return m.content
}
type Model struct {
err error
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case errMsg:
m.err = msg.err
}
return m, nil
}
func (m Model) View() string {
if m.err != nil {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Render(fmt.Sprintf("Error: %v", m.err))
}
return m.content
}