charm-stack

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Charm Stack TUI Development

Charm栈 TUI 开发

Build beautiful, functional terminal user interfaces using the Charm stack: Bubbletea (framework), Bubbles (components), Lipgloss (styling), and Huh (forms).
使用Charm技术栈构建美观、实用的终端用户界面:Bubbletea(框架)、Bubbles(组件库)、Lipgloss(样式库)、Huh(表单库)。

Your Role: TUI Architect

你的角色:TUI架构师

You build terminal applications using Elm Architecture patterns. You:
Implement Model-Update-View - Core Bubbletea pattern ✅ Compose Bubbles components - Spinners, lists, text inputs ✅ Style with Lipgloss - Colors, borders, layouts ✅ Build forms with Huh - Interactive prompts ✅ Handle messages properly - KeyMsg, WindowMsg, custom messages ✅ Follow project patterns - Module structure from CLY
Do NOT fight the framework - Use Elm Architecture ❌ Do NOT skip Init - Commands need initialization ❌ Do NOT ignore tea.Cmd - Critical for async operations
你需要使用Elm架构模式构建终端应用,你需要:
实现 Model-Update-View - Bubbletea核心模式 ✅ 组合Bubbles组件 - 加载动画、列表、文本输入框 ✅ 使用Lipgloss实现样式 - 颜色、边框、布局 ✅ 使用Huh构建表单 - 交互式提示框 ✅ 正确处理消息 - KeyMsg、WindowMsg、自定义消息 ✅ 遵循项目模式 - 符合CLY的模块结构
不要违背框架设计 - 必须使用Elm架构 ❌ 不要省略Init方法 - 命令需要初始化 ❌ 不要忽略tea.Cmd - 对异步操作至关重要

Core Architecture: The Elm Architecture

核心架构:Elm架构

The Three Functions

三个核心函数

Every Bubbletea program has three parts:
Model - Application state
go
type model struct {
    cursor   int
    choices  []string
    selected map[int]struct{}
}
Init - Initial command
go
func (m model) Init() tea.Cmd {
    return nil  // or return a command
}
Update - Handle messages, update state
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        // Handle keyboard
    }
    return m, nil
}
View - Render UI
go
func (m model) View() string {
    return "Hello, World!"
}
每个Bubbletea程序都包含三个部分:
Model - 应用状态
go
type model struct {
    cursor   int
    choices  []string
    selected map[int]struct{}
}
Init - 初始命令
go
func (m model) Init() tea.Cmd {
    return nil  // 或返回一个命令
}
Update - 处理消息,更新状态
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        // 处理键盘输入
    }
    return m, nil
}
View - 渲染UI
go
func (m model) View() string {
    return "Hello, World!"
}

Message Flow

消息流

User Input → Msg → Update → Model → View → Screen
    ↑                                    ↓
    └────────── tea.Cmd ─────────────────┘
Key concepts:
  • Messages are immutable events
  • Update returns new model (don't mutate)
  • Commands run async, generate more messages
  • View is pure function of model state
用户输入 → Msg → Update → Model → View → 屏幕
    ↑                                    ↓
    └────────── tea.Cmd ─────────────────┘
核心概念:
  • 消息是不可变的事件
  • Update返回新的model(不要修改原model)
  • 命令异步运行,生成更多消息
  • View是基于model状态的纯函数

Bubbletea Patterns

Bubbletea 常用模式

Basic Program

基础程序

go
package main

import (
    "fmt"
    "os"

    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    choices  []string
    cursor   int
    selected map[int]struct{}
}

func initialModel() model {
    return model{
        choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
        selected: make(map[int]struct{}),
    }
}

func (m model) Init() tea.Cmd {
    return nil
}

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":
            if m.cursor > 0 {
                m.cursor--
            }

        case "down", "j":
            if m.cursor < len(m.choices)-1 {
                m.cursor++
            }

        case "enter", " ":
            _, ok := m.selected[m.cursor]
            if ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "What should we buy?\n\n"

    for i, choice := range m.choices {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }

        checked := " "
        if _, ok := m.selected[i]; ok {
            checked = "x"
        }

        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }

    s += "\nPress q to quit.\n"
    return s
}

func main() {
    p := tea.NewProgram(initialModel())
    if _, err := p.Run(); err != nil {
        fmt.Printf("Error: %v", err)
        os.Exit(1)
    }
}
go
package main

import (
    "fmt"
    "os"

    tea "github.com/charmbracelet/bubbletea"
)

type model struct {
    choices  []string
    cursor   int
    selected map[int]struct{}
}

func initialModel() model {
    return model{
        choices:  []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
        selected: make(map[int]struct{}),
    }
}

func (m model) Init() tea.Cmd {
    return nil
}

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":
            if m.cursor > 0 {
                m.cursor--
            }

        case "down", "j":
            if m.cursor < len(m.choices)-1 {
                m.cursor++
            }

        case "enter", " ":
            _, ok := m.selected[m.cursor]
            if ok {
                delete(m.selected, m.cursor)
            } else {
                m.selected[m.cursor] = struct{}{}
            }
        }
    }
    return m, nil
}

func (m model) View() string {
    s := "What should we buy?\n\n"

    for i, choice := range m.choices {
        cursor := " "
        if m.cursor == i {
            cursor = ">"
        }

        checked := " "
        if _, ok := m.selected[i]; ok {
            checked = "x"
        }

        s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
    }

    s += "\nPress q to quit.\n"
    return s
}

func main() {
    p := tea.NewProgram(initialModel())
    if _, err := p.Run(); err != nil {
        fmt.Printf("Error: %v", err)
        os.Exit(1)
    }
}

Commands (tea.Cmd)

命令 (tea.Cmd)

Commands enable async operations. They return messages.
Simple command:
go
func checkServer() tea.Msg {
    // Do work
    return statusMsg{online: true}
}

// In Update:
case tea.KeyMsg:
    if msg.String() == "c" {
        return m, checkServer  // Execute command
    }
Command that runs async:
go
func fetchData() tea.Cmd {
    return func() tea.Msg {
        resp, err := http.Get("https://api.example.com/data")
        if err != nil {
            return errMsg{err}
        }
        return dataMsg{resp}
    }
}
Batch commands:
go
return m, tea.Batch(
    cmd1,
    cmd2,
    cmd3,
)
Tick command (for animations):
go
type tickMsg time.Time

func tick() tea.Cmd {
    return tea.Tick(time.Second, func(t time.Time) tea.Msg {
        return tickMsg(t)
    })
}

// In Init:
func (m model) Init() tea.Cmd {
    return tick()
}

// In Update:
case tickMsg:
    m.lastTick = time.Time(msg)
    return m, tick()  // Keep ticking
命令用于实现异步操作,执行后返回消息。
简单命令:
go
func checkServer() tea.Msg {
    // 执行任务
    return statusMsg{online: true}
}

// 在Update中:
case tea.KeyMsg:
    if msg.String() == "c" {
        return m, checkServer  // 执行命令
    }
异步运行的命令:
go
func fetchData() tea.Cmd {
    return func() tea.Msg {
        resp, err := http.Get("https://api.example.com/data")
        if err != nil {
            return errMsg{err}
        }
        return dataMsg{resp}
    }
}
批量命令:
go
return m, tea.Batch(
    cmd1,
    cmd2,
    cmd3,
)
Tick命令(用于动画):
go
type tickMsg time.Time

func tick() tea.Cmd {
    return tea.Tick(time.Second, func(t time.Time) tea.Msg {
        return tickMsg(t)
    })
}

// 在Init中:
func (m model) Init() tea.Cmd {
    return tick()
}

// 在Update中:
case tickMsg:
    m.lastTick = time.Time(msg)
    return m, tick()  // 持续触发tick

Window Size Handling

窗口大小处理

go
type model struct {
    width  int
    height int
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height
        return m, nil
    }
    return m, nil
}

func (m model) View() string {
    return lipgloss.Place(
        m.width,
        m.height,
        lipgloss.Center,
        lipgloss.Center,
        "Centered text",
    )
}
go
type model struct {
    width  int
    height int
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height
        return m, nil
    }
    return m, nil
}

func (m model) View() string {
    return lipgloss.Place(
        m.width,
        m.height,
        lipgloss.Center,
        lipgloss.Center,
        "Centered text",
    )
}

Program Options

程序选项

go
p := tea.NewProgram(
    initialModel(),
    tea.WithAltScreen(),       // Use alternate screen buffer
    tea.WithMouseCellMotion(), // Enable mouse
)
go
p := tea.NewProgram(
    initialModel(),
    tea.WithAltScreen(),       // 使用备用屏幕缓冲区
    tea.WithMouseCellMotion(), // 启用鼠标支持
)

Bubbles Components

Bubbles 组件

Bubbles provides ready-made components. Each is a tea.Model.
Bubbles提供开箱即用的组件,每个组件都是一个tea.Model。

Spinner

加载动画(Spinner)

go
import "github.com/charmbracelet/bubbles/spinner"

type model struct {
    spinner spinner.Model
}

func initialModel() model {
    s := spinner.New()
    s.Spinner = spinner.Dot
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
    return model{spinner: s}
}

func (m model) Init() tea.Cmd {
    return m.spinner.Tick
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.spinner, cmd = m.spinner.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.spinner.View() + " Loading..."
}
Spinner types:
  • spinner.Line
  • spinner.Dot
  • spinner.MiniDot
  • spinner.Jump
  • spinner.Pulse
  • spinner.Points
  • spinner.Globe
  • spinner.Moon
go
import "github.com/charmbracelet/bubbles/spinner"

type model struct {
    spinner spinner.Model
}

func initialModel() model {
    s := spinner.New()
    s.Spinner = spinner.Dot
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
    return model{spinner: s}
}

func (m model) Init() tea.Cmd {
    return m.spinner.Tick
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd
    m.spinner, cmd = m.spinner.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.spinner.View() + " Loading..."
}
加载动画类型:
  • spinner.Line
  • spinner.Dot
  • spinner.MiniDot
  • spinner.Jump
  • spinner.Pulse
  • spinner.Points
  • spinner.Globe
  • spinner.Moon

Text Input

文本输入框

go
import "github.com/charmbracelet/bubbles/textinput"

type model struct {
    textInput textinput.Model
}

func initialModel() model {
    ti := textinput.New()
    ti.Placeholder = "Enter your name"
    ti.Focus()
    ti.CharLimit = 156
    ti.Width = 20

    return model{textInput: ti}
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd

    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "enter":
            name := m.textInput.Value()
            // Use the name
            return m, tea.Quit
        }
    }

    m.textInput, cmd = m.textInput.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return fmt.Sprintf(
        "What's your name?\n\n%s\n\n%s",
        m.textInput.View(),
        "(esc to quit)",
    )
}
go
import "github.com/charmbracelet/bubbles/textinput"

type model struct {
    textInput textinput.Model
}

func initialModel() model {
    ti := textinput.New()
    ti.Placeholder = "Enter your name"
    ti.Focus()
    ti.CharLimit = 156
    ti.Width = 20

    return model{textInput: ti}
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmd tea.Cmd

    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "enter":
            name := m.textInput.Value()
            // 使用输入的名称
            return m, tea.Quit
        }
    }

    m.textInput, cmd = m.textInput.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return fmt.Sprintf(
        "What's your name?\n\n%s\n\n%s",
        m.textInput.View(),
        "(esc to quit)",
    )
}

List

列表

go
import "github.com/charmbracelet/bubbles/list"

type item struct {
    title, desc string
}

func (i item) Title() string       { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }

type model struct {
    list list.Model
}

func initialModel() model {
    items := []list.Item{
        item{title: "Raspberry Pi", desc: "A small computer"},
        item{title: "Arduino", desc: "Microcontroller"},
        item{title: "ESP32", desc: "WiFi & Bluetooth"},
    }

    l := list.New(items, list.NewDefaultDelegate(), 0, 0)
    l.Title = "Hardware"

    return model{list: l}
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.list.SetSize(msg.Width, msg.Height)

    case tea.KeyMsg:
        if msg.String() == "enter" {
            selected := m.list.SelectedItem().(item)
            // Use selected
        }
    }

    var cmd tea.Cmd
    m.list, cmd = m.list.Update(msg)
    return m, cmd
}
go
import "github.com/charmbracelet/bubbles/list"

type item struct {
    title, desc string
}

func (i item) Title() string       { return i.title }
func (i item) Description() string { return i.desc }
func (i item) FilterValue() string { return i.title }

type model struct {
    list list.Model
}

func initialModel() model {
    items := []list.Item{
        item{title: "Raspberry Pi", desc: "A small computer"},
        item{title: "Arduino", desc: "Microcontroller"},
        item{title: "ESP32", desc: "WiFi & Bluetooth"},
    }

    l := list.New(items, list.NewDefaultDelegate(), 0, 0)
    l.Title = "Hardware"

    return model{list: l}
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.list.SetSize(msg.Width, msg.Height)

    case tea.KeyMsg:
        if msg.String() == "enter" {
            selected := m.list.SelectedItem().(item)
            // 使用选中的项
        }
    }

    var cmd tea.Cmd
    m.list, cmd = m.list.Update(msg)
    return m, cmd
}

Table

表格

go
import "github.com/charmbracelet/bubbles/table"

func initialModel() model {
    columns := []table.Column{
        {Title: "ID", Width: 4},
        {Title: "Name", Width: 10},
        {Title: "Status", Width: 10},
    }

    rows := []table.Row{
        {"1", "Alice", "Active"},
        {"2", "Bob", "Inactive"},
    }

    t := table.New(
        table.WithColumns(columns),
        table.WithRows(rows),
        table.WithFocused(true),
        table.WithHeight(7),
    )

    s := table.DefaultStyles()
    s.Header = s.Header.
        BorderStyle(lipgloss.NormalBorder()).
        BorderForeground(lipgloss.Color("240"))
    t.SetStyles(s)

    return model{table: t}
}
go
import "github.com/charmbracelet/bubbles/table"

func initialModel() model {
    columns := []table.Column{
        {Title: "ID", Width: 4},
        {Title: "Name", Width: 10},
        {Title: "Status", Width: 10},
    }

    rows := []table.Row{
        {"1", "Alice", "Active"},
        {"2", "Bob", "Inactive"},
    }

    t := table.New(
        table.WithColumns(columns),
        table.WithRows(rows),
        table.WithFocused(true),
        table.WithHeight(7),
    )

    s := table.DefaultStyles()
    s.Header = s.Header.
        BorderStyle(lipgloss.NormalBorder()).
        BorderForeground(lipgloss.Color("240"))
    t.SetStyles(s)

    return model{table: t}
}

Viewport (Scrolling)

视口(滚动功能)

go
import "github.com/charmbracelet/bubbles/viewport"

type model struct {
    viewport viewport.Model
    content  string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.viewport = viewport.New(msg.Width, msg.Height)
        m.viewport.SetContent(m.content)
    }

    var cmd tea.Cmd
    m.viewport, cmd = m.viewport.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.viewport.View()
}
go
import "github.com/charmbracelet/bubbles/viewport"

type model struct {
    viewport viewport.Model
    content  string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.viewport = viewport.New(msg.Width, msg.Height)
        m.viewport.SetContent(m.content)
    }

    var cmd tea.Cmd
    m.viewport, cmd = m.viewport.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.viewport.View()
}

Progress

进度条

go
import "github.com/charmbracelet/bubbles/progress"

type model struct {
    progress progress.Model
    percent  float64
}

func initialModel() model {
    return model{
        progress: progress.New(progress.WithDefaultGradient()),
        percent:  0.0,
    }
}

func (m model) View() string {
    return "\n" + m.progress.ViewAs(m.percent) + "\n\n"
}
go
import "github.com/charmbracelet/bubbles/progress"

type model struct {
    progress progress.Model
    percent  float64
}

func initialModel() model {
    return model{
        progress: progress.New(progress.WithDefaultGradient()),
        percent:  0.0,
    }
}

func (m model) View() string {
    return "\n" + m.progress.ViewAs(m.percent) + "\n\n"
}

Lipgloss Styling

Lipgloss 样式

Basic Styles

基础样式

go
import "github.com/charmbracelet/lipgloss"

var (
    // Define styles
    titleStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(lipgloss.Color("170")).
        Background(lipgloss.Color("235")).
        Padding(0, 1)

    errorStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("196")).
        Bold(true)

    successStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("42"))
)

// Use styles
title := titleStyle.Render("Hello")
err := errorStyle.Render("Error!")
go
import "github.com/charmbracelet/lipgloss"

var (
    // 定义样式
    titleStyle = lipgloss.NewStyle().
        Bold(true).
        Foreground(lipgloss.Color("170")).
        Background(lipgloss.Color("235")).
        Padding(0, 1)

    errorStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("196")).
        Bold(true)

    successStyle = lipgloss.NewStyle().
        Foreground(lipgloss.Color("42"))
)

// 使用样式
title := titleStyle.Render("Hello")
err := errorStyle.Render("Error!")

Colors

颜色

go
// ANSI 16 colors
lipgloss.Color("5")      // magenta

// ANSI 256 colors
lipgloss.Color("86")     // aqua
lipgloss.Color("201")    // hot pink

// True color (hex)
lipgloss.Color("#0000FF") // blue
lipgloss.Color("#FF6B6B") // red

// Adaptive (light/dark)
lipgloss.AdaptiveColor{
    Light: "236",
    Dark:  "248",
}
go
// ANSI 16色
lipgloss.Color("5")      // 洋红色

// ANSI 256色
lipgloss.Color("86")     // 水绿色
lipgloss.Color("201")    // 亮粉色

// 真彩色(十六进制)
lipgloss.Color("#0000FF") // 蓝色
lipgloss.Color("#FF6B6B") // 红色

// 自适应(亮色/暗色模式)
lipgloss.AdaptiveColor{
    Light: "236",
    Dark:  "248",
}

Layout & Spacing

布局与间距

go
style := lipgloss.NewStyle().
    Width(50).
    Height(10).
    Padding(1, 2).       // top/bottom, left/right
    Margin(1, 2, 3, 4).  // top, right, bottom, left
    Align(lipgloss.Center)
go
style := lipgloss.NewStyle().
    Width(50).
    Height(10).
    Padding(1, 2).       // 上下、左右
    Margin(1, 2, 3, 4).  // 上、右、下、左
    Align(lipgloss.Center)

Borders

边框

go
style := lipgloss.NewStyle().
    Border(lipgloss.RoundedBorder()).
    BorderForeground(lipgloss.Color("63")).
    Padding(1, 2)

// Border types
lipgloss.NormalBorder()
lipgloss.RoundedBorder()
lipgloss.ThickBorder()
lipgloss.DoubleBorder()
lipgloss.HiddenBorder()

// Selective borders
style.BorderTop(true).
    BorderLeft(true)
go
style := lipgloss.NewStyle().
    Border(lipgloss.RoundedBorder()).
    BorderForeground(lipgloss.Color("63")).
    Padding(1, 2)

// 边框类型
lipgloss.NormalBorder()
lipgloss.RoundedBorder()
lipgloss.ThickBorder()
lipgloss.DoubleBorder()
lipgloss.HiddenBorder()

// 选择性设置边框
style.BorderTop(true).
    BorderLeft(true)

Joining Layouts

布局拼接

go
// Horizontal
row := lipgloss.JoinHorizontal(
    lipgloss.Top,     // Alignment
    box1, box2, box3,
)

// Vertical
col := lipgloss.JoinVertical(
    lipgloss.Left,
    box1, box2, box3,
)

// Positions: Top, Center, Bottom, Left, Right
go
// 水平拼接
row := lipgloss.JoinHorizontal(
    lipgloss.Top,     // 对齐方式
    box1, box2, box3,
)

// 垂直拼接
col := lipgloss.JoinVertical(
    lipgloss.Left,
    box1, box2, box3,
)

// 可选位置:Top, Center, Bottom, Left, Right

Positioning

定位

go
// Place in whitespace
centered := lipgloss.Place(
    width, height,
    lipgloss.Center,    // horizontal
    lipgloss.Center,    // vertical
    content,
)
go
// 放置在空白区域中
centered := lipgloss.Place(
    width, height,
    lipgloss.Center,    // 水平对齐
    lipgloss.Center,    // 垂直对齐
    content,
)

Advanced Styling

高级样式

go
style := lipgloss.NewStyle().
    Bold(true).
    Italic(true).
    Underline(true).
    Strikethrough(true).
    Blink(true).
    Faint(true).
    Reverse(true)
go
style := lipgloss.NewStyle().
    Bold(true).
    Italic(true).
    Underline(true).
    Strikethrough(true).
    Blink(true).
    Faint(true).
    Reverse(true)

Huh Forms

Huh 表单

Build interactive forms and prompts.
用于构建交互式表单和提示框。

Basic Form

基础表单

go
import "github.com/charmbracelet/huh"

var (
    burger   string
    toppings []string
    name     string
)

func runForm() error {
    form := huh.NewForm(
        huh.NewGroup(
            huh.NewSelect[string]().
                Title("Choose your burger").
                Options(
                    huh.NewOption("Classic", "classic"),
                    huh.NewOption("Chicken", "chicken"),
                    huh.NewOption("Veggie", "veggie"),
                ).
                Value(&burger),

            huh.NewMultiSelect[string]().
                Title("Toppings").
                Options(
                    huh.NewOption("Lettuce", "lettuce"),
                    huh.NewOption("Tomato", "tomato"),
                    huh.NewOption("Cheese", "cheese"),
                ).
                Limit(3).
                Value(&toppings),
        ),

        huh.NewGroup(
            huh.NewInput().
                Title("What's your name?").
                Value(&name).
                Validate(func(s string) error {
                    if s == "" {
                        return fmt.Errorf("name required")
                    }
                    return nil
                }),
        ),
    )

    return form.Run()
}
go
import "github.com/charmbracelet/huh"

var (
    burger   string
    toppings []string
    name     string
)

func runForm() error {
    form := huh.NewForm(
        huh.NewGroup(
            huh.NewSelect[string]().
                Title("Choose your burger").
                Options(
                    huh.NewOption("Classic", "classic"),
                    huh.NewOption("Chicken", "chicken"),
                    huh.NewOption("Veggie", "veggie"),
                ).
                Value(&burger),

            huh.NewMultiSelect[string]().
                Title("Toppings").
                Options(
                    huh.NewOption("Lettuce", "lettuce"),
                    huh.NewOption("Tomato", "tomato"),
                    huh.NewOption("Cheese", "cheese"),
                ).
                Limit(3).
                Value(&toppings),
        ),

        huh.NewGroup(
            huh.NewInput().
                Title("What's your name?").
                Value(&name).
                Validate(func(s string) error {
                    if s == "" {
                        return fmt.Errorf("name required")
                    }
                    return nil
                }),
        ),
    )

    return form.Run()
}

Field Types

字段类型

Input - Single line text:
go
huh.NewInput().
    Title("Username").
    Placeholder("Enter username").
    Value(&username)
Text - Multi-line text:
go
huh.NewText().
    Title("Description").
    CharLimit(400).
    Value(&description)
Select - Choose one:
go
huh.NewSelect[string]().
    Title("Pick one").
    Options(
        huh.NewOption("Option 1", "opt1"),
        huh.NewOption("Option 2", "opt2"),
    ).
    Value(&choice)
MultiSelect - Choose multiple:
go
huh.NewMultiSelect[string]().
    Title("Pick several").
    Options(...).
    Limit(3).
    Value(&choices)
Confirm - Yes/No:
go
huh.NewConfirm().
    Title("Are you sure?").
    Affirmative("Yes").
    Negative("No").
    Value(&confirmed)
Input - 单行文本:
go
huh.NewInput().
    Title("Username").
    Placeholder("Enter username").
    Value(&username)
Text - 多行文本:
go
huh.NewText().
    Title("Description").
    CharLimit(400).
    Value(&description)
Select - 单选:
go
huh.NewSelect[string]().
    Title("Pick one").
    Options(
        huh.NewOption("Option 1", "opt1"),
        huh.NewOption("Option 2", "opt2"),
    ).
    Value(&choice)
MultiSelect - 多选:
go
huh.NewMultiSelect[string]().
    Title("Pick several").
    Options(...).
    Limit(3).
    Value(&choices)
Confirm - 确认框(是/否):
go
huh.NewConfirm().
    Title("Are you sure?").
    Affirmative("Yes").
    Negative("No").
    Value(&confirmed)

Accessible Mode

无障碍模式

go
form := huh.NewForm(...)
form.WithAccessible(true)  // Screen reader friendly
go
form := huh.NewForm(...)
form.WithAccessible(true)  // 对屏幕阅读器友好

In Bubble Tea

在Bubble Tea中使用

go
type model struct {
    form *huh.Form
}

func (m model) Init() tea.Cmd {
    return m.form.Init()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    form, cmd := m.form.Update(msg)
    if f, ok := form.(*huh.Form); ok {
        m.form = f
    }

    if m.form.State == huh.StateCompleted {
        // Form done, get values
        return m, tea.Quit
    }

    return m, cmd
}

func (m model) View() string {
    if m.form.State == huh.StateCompleted {
        return "Done!\n"
    }
    return m.form.View()
}
go
type model struct {
    form *huh.Form
}

func (m model) Init() tea.Cmd {
    return m.form.Init()
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    form, cmd := m.form.Update(msg)
    if f, ok := form.(*huh.Form); ok {
        m.form = f
    }

    if m.form.State == huh.StateCompleted {
        // 表单填写完成,获取值
        return m, tea.Quit
    }

    return m, cmd
}

func (m model) View() string {
    if m.form.State == huh.StateCompleted {
        return "Done!\n"
    }
    return m.form.View()
}

CLY Project Patterns

CLY项目模式

Module Structure

模块结构

modules/demo/spinner/
├── cmd.go        # Register() and run()
└── spinner.go    # Bubbletea model
cmd.go:
go
package spinner

import (
    tea "github.com/charmbracelet/bubbletea"
    "github.com/spf13/cobra"
)

func Register(parent *cobra.Command) {
    cmd := &cobra.Command{
        Use:   "spinner",
        Short: "Spinner demo",
        RunE:  run,
    }
    parent.AddCommand(cmd)
}

func run(cmd *cobra.Command, args []string) error {
    p := tea.NewProgram(initialModel())
    if _, err := p.Run(); err != nil {
        return err
    }
    return nil
}
spinner.go:
go
package spinner

import (
    "github.com/charmbracelet/bubbles/spinner"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

type model struct {
    spinner spinner.Model
}

func initialModel() model {
    s := spinner.New()
    s.Spinner = spinner.Dot
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
    return model{spinner: s}
}

func (m model) Init() tea.Cmd {
    return m.spinner.Tick
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "q" {
            return m, tea.Quit
        }
    }

    var cmd tea.Cmd
    m.spinner, cmd = m.spinner.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.spinner.View() + " Loading...\n"
}
modules/demo/spinner/
├── cmd.go        # Register() 和 run() 方法
└── spinner.go    # Bubbletea model定义
cmd.go:
go
package spinner

import (
    tea "github.com/charmbracelet/bubbletea"
    "github.com/spf13/cobra"
)

func Register(parent *cobra.Command) {
    cmd := &cobra.Command{
        Use:   "spinner",
        Short: "Spinner demo",
        RunE:  run,
    }
    parent.AddCommand(cmd)
}

func run(cmd *cobra.Command, args []string) error {
    p := tea.NewProgram(initialModel())
    if _, err := p.Run(); err != nil {
        return err
    }
    return nil
}
spinner.go:
go
package spinner

import (
    "github.com/charmbracelet/bubbles/spinner"
    tea "github.com/charmbracelet/bubbletea"
    "github.com/charmbracelet/lipgloss"
)

type model struct {
    spinner spinner.Model
}

func initialModel() model {
    s := spinner.New()
    s.Spinner = spinner.Dot
    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
    return model{spinner: s}
}

func (m model) Init() tea.Cmd {
    return m.spinner.Tick
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "q" {
            return m, tea.Quit
        }
    }

    var cmd tea.Cmd
    m.spinner, cmd = m.spinner.Update(msg)
    return m, cmd
}

func (m model) View() string {
    return m.spinner.View() + " Loading...\n"
}

Common Patterns

常用模式

Loading State

加载状态

go
type model struct {
    loading bool
    spinner spinner.Model
    data    []string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "r" {
            m.loading = true
            return m, fetchData
        }

    case dataMsg:
        m.loading = false
        m.data = msg.data
        return m, nil
    }

    if m.loading {
        var cmd tea.Cmd
        m.spinner, cmd = m.spinner.Update(msg)
        return m, cmd
    }

    return m, nil
}

func (m model) View() string {
    if m.loading {
        return m.spinner.View() + " Loading data..."
    }
    return renderData(m.data)
}
go
type model struct {
    loading bool
    spinner spinner.Model
    data    []string
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        if msg.String() == "r" {
            m.loading = true
            return m, fetchData
        }

    case dataMsg:
        m.loading = false
        m.data = msg.data
        return m, nil
    }

    if m.loading {
        var cmd tea.Cmd
        m.spinner, cmd = m.spinner.Update(msg)
        return m, cmd
    }

    return m, nil
}

func (m model) View() string {
    if m.loading {
        return m.spinner.View() + " Loading data..."
    }
    return renderData(m.data)
}

Error Handling

错误处理

go
type model struct {
    err error
}

type errMsg 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
    }
    return m, nil
}

func (m model) View() string {
    if m.err != nil {
        return errorStyle.Render("Error: " + m.err.Error())
    }
    return normalView()
}
go
type model struct {
    err error
}

type errMsg 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
    }
    return m, nil
}

func (m model) View() string {
    if m.err != nil {
        return errorStyle.Render("Error: " + m.err.Error())
    }
    return normalView()
}

Multi-View Navigation

多视图导航

go
type view int

const (
    viewMenu view = iota
    viewList
    viewDetail
)

type model struct {
    currentView view
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "1":
            m.currentView = viewMenu
        case "2":
            m.currentView = viewList
        case "3":
            m.currentView = viewDetail
        }
    }
    return m, nil
}

func (m model) View() string {
    switch m.currentView {
    case viewMenu:
        return renderMenu()
    case viewList:
        return renderList()
    case viewDetail:
        return renderDetail()
    }
    return ""
}
go
type view int

const (
    viewMenu view = iota
    viewList
    viewDetail
)

type model struct {
    currentView view
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "1":
            m.currentView = viewMenu
        case "2":
            m.currentView = viewList
        case "3":
            m.currentView = viewDetail
        }
    }
    return m, nil
}

func (m model) View() string {
    switch m.currentView {
    case viewMenu:
        return renderMenu()
    case viewList:
        return renderList()
    case viewDetail:
        return renderDetail()
    }
    return ""
}

Best Practices

最佳实践

Model Immutability

Model不可变性

✅ GOOD - Return new state:
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    m.counter++  // Modify copy
    return m, nil
}
❌ BAD - Mutate pointer:
go
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    m.counter++  // Mutates original
    return m, nil
}
✅ 正确 - 返回新状态:
go
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    m.counter++  // 修改副本
    return m, nil
}
❌ 错误 - 修改指针:
go
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    m.counter++  // 修改原对象
    return m, nil
}

Quit Handling

退出处理

Always handle quit signals:
go
case tea.KeyMsg:
    switch msg.String() {
    case "ctrl+c", "q", "esc":
        return m, tea.Quit
    }
始终处理退出信号:
go
case tea.KeyMsg:
    switch msg.String() {
    case "ctrl+c", "q", "esc":
        return m, tea.Quit
    }

Alt Screen

备用屏幕

Use alt screen for full-screen apps:
go
p := tea.NewProgram(
    initialModel(),
    tea.WithAltScreen(),
)
全屏应用使用备用屏幕:
go
p := tea.NewProgram(
    initialModel(),
    tea.WithAltScreen(),
)

Component Composition

组件组合

Embed Bubbles components:
go
type model struct {
    spinner   spinner.Model
    textInput textinput.Model
    list      list.Model
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmds []tea.Cmd
    var cmd tea.Cmd

    m.spinner, cmd = m.spinner.Update(msg)
    cmds = append(cmds, cmd)

    m.textInput, cmd = m.textInput.Update(msg)
    cmds = append(cmds, cmd)

    return m, tea.Batch(cmds...)
}
嵌入Bubbles组件:
go
type model struct {
    spinner   spinner.Model
    textInput textinput.Model
    list      list.Model
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    var cmds []tea.Cmd
    var cmd tea.Cmd

    m.spinner, cmd = m.spinner.Update(msg)
    cmds = append(cmds, cmd)

    m.textInput, cmd = m.textInput.Update(msg)
    cmds = append(cmds, cmd)

    return m, tea.Batch(cmds...)
}

Checklist

检查清单

  • Model contains all state
  • Init returns initial command
  • Update handles all message types
  • Update returns new model (immutable)
  • View is pure function
  • Quit handling present
  • Window resize handled
  • Commands for async ops
  • Bubbles components updated
  • Lipgloss for all styling
  • Follows CLY module structure
  • Model包含所有状态
  • Init返回初始命令
  • Update处理所有消息类型
  • Update返回新model(不可变)
  • View是纯函数
  • 包含退出处理逻辑
  • 处理窗口 resize 事件
  • 异步操作使用命令实现
  • 正确更新Bubbles组件
  • 所有样式使用Lipgloss实现
  • 遵循CLY模块结构

Resources

资源