重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
tui-component-design by colonyops/hive
npx skills add https://github.com/colonyops/hive --skill tui-component-design基于 hive diff 查看器实现,使用 Bubbletea v2 和 Charm 生态系统构建可维护、可测试的 TUI 组件的最佳实践。
每个组件应位于其自己的文件中,具有清晰的边界:
internal/tui/diff/
├── model.go # 顶层组合器,协调子组件
├── diffviewer.go # 带滚动和选择的差异内容显示
├── filetree.go # 带展开/折叠功能的文件导航树
├── lineparse.go # 用于解析差异行的纯函数工具
├── delta.go # 外部工具集成(语法高亮)
└── utils.go # 共享工具函数
关键原则: 每个文件应代表一个具有自己的 Model、Update 和 View 方法的组件。
对于复杂的 UI,使用组合器模式:
// 顶层 Model 组合子组件
type Model struct {
fileTree FileTreeModel // 左侧面板
diffViewer DiffViewerModel // 右侧面板
focused FocusedPanel // 哪个组件拥有焦点
helpDialog *components.HelpDialog // 模态覆盖层
showHelp bool // 对话框可见性状态
}
// Update 委托给获得焦点的组件
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
// 1. 包含所有状态的 Model 结构体
type ComponentModel struct {
// 数据
items []Item
// UI 状态
selected int
offset int
width int
height int
// 功能标志
iconStyle IconStyle
expanded bool
}
// 2. 带依赖项的构造函数
func NewComponent(data []Item, cfg *config.Config) ComponentModel {
return ComponentModel{
items: data,
selected: 0,
iconStyle: determineIconStyle(cfg),
}
}
// 3. Update 处理消息
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
// 4. View 渲染输出
func (m ComponentModel) View() string {
return m.render()
}
// 5. 复杂逻辑的辅助方法
func (m ComponentModel) render() string {
// 渲染逻辑在此
}
不良做法:
// 状态隐藏在闭包或包变量中
var currentSelection int
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
currentSelection++ // 修改隐藏状态
}
良好做法:
// 所有状态在模型中显式声明
type Model struct {
currentSelection int
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.currentSelection++ // 清晰、可追踪的状态变更
return m, nil
}
type DiffViewerModel struct {
// 不可变数据
file *gitdiff.File
content string
lines []string
// 可变 UI 状态
offset int // 滚动位置
cursorLine int // 当前行
selectionMode bool // 可视模式激活
selectionStart int // 选择锚点
}
优点:
对于语法高亮或外部工具调用等昂贵操作:
type ComponentModel struct {
cache map[string]*CachedResult
loading bool
}
// 1. 启动异步操作,立即返回
func (m *ComponentModel) SetData(data *Data) tea.Cmd {
filePath := data.Path
// 首先检查缓存
if cached, ok := m.cache[filePath]; ok {
m.content = cached.content
m.lines = cached.lines
return nil
}
// 标记为加载中,开始异步
m.loading = true
return func() tea.Msg {
content, lines := generateContent(data)
return contentGeneratedMsg{filePath, content, lines}
}
}
// 2. 处理完成消息
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case contentGeneratedMsg:
// 缓存结果
m.cache[msg.filePath] = &CachedResult{
content: msg.content,
lines: msg.lines,
}
// 更新显示
m.content = msg.content
m.lines = msg.lines
m.loading = false
}
return m, nil
}
关键点:
对于像 delta(语法高亮)这样的工具:
// 1. 在初始化时检查一次可用性
func NewDiffViewer(file *gitdiff.File) DiffViewerModel {
deltaAvailable := CheckDeltaAvailable() == nil
return DiffViewerModel{
deltaAvailable: deltaAvailable,
}
}
// 2. 分离纯函数以提高可测试性
func generateDiffContent(file *gitdiff.File, deltaAvailable bool) (string, []string) {
diff := buildUnifiedDiff(file)
if !deltaAvailable {
return diff, strings.Split(diff, "\n")
}
// 应用语法高亮
return applyDelta(diff)
}
// 3. 使其异步化并正确处理错误
func (m *ComponentModel) loadContent(file *gitdiff.File) tea.Cmd {
return func() tea.Msg {
content, lines := generateDiffContent(file, m.deltaAvailable)
return contentReadyMsg{content, lines}
}
}
对于具有普通/可视模式的 vim 风格界面:
type Model struct {
mode Mode // 普通、可视、插入
selectionMode bool // 可视模式激活
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// 首先处理模式转换
if msg.Code == 'v' && !m.selectionMode {
m.selectionMode = true
m.selectionStart = m.cursorLine
return m, nil
}
if msg.Code == tea.KeyEscape && m.selectionMode {
m.selectionMode = false
return m, nil
}
// 处理特定模式的行为
if m.selectionMode {
return m.handleVisualMode(msg)
}
return m.handleNormalMode(msg)
}
return m, nil
}
对于可视选择(高亮行):
type Model struct {
selectionMode bool
selectionStart int // 锚点
cursorLine int // 活动端
}
// 辅助函数,获取规范化的选择范围
func (m Model) SelectionRange() (start, end int, active bool) {
if !m.selectionMode {
return 0, 0, false
}
start = m.selectionStart
end = m.cursorLine
if start > end {
start, end = end, start
}
return start, end, true
}
// 在渲染中使用
func (m Model) View() string {
start, end, active := m.SelectionRange()
for i, line := range m.lines {
if active && i >= start && i <= end {
line = highlightStyle.Render(line)
}
// ... 渲染行
}
}
对于具有固定尺寸的可滚动内容:
type Model struct {
lines []string
offset int // 顶部可见行
height int // 视口高度
}
// 计算可见范围
func (m Model) visibleLines() []string {
start := m.offset
end := min(m.offset + m.contentHeight(), len(m.lines))
return m.lines[start:end]
}
// 内容高度(不包括固定的 UI 元素)
func (m Model) contentHeight() int {
return m.height - headerHeight - footerHeight
}
// 带光标跟踪的滚动
func (m Model) scrollDown() Model {
// 首先移动光标
if m.cursorLine < len(m.lines)-1 {
m.cursorLine++
}
// 如果光标移出视图,调整视口
visibleBottom := m.offset + m.contentHeight() - 1
if m.cursorLine > visibleBottom {
m.offset++
}
return m
}
关键原则: 光标先移动,视口跟随以保持光标可见。
跳转到编辑器中特定行的模式:
func (m Model) openInEditor(filePath string, lineNum int) tea.Cmd {
return func() tea.Msg {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
}
// 格式:editor +line file
arg := fmt.Sprintf("+%d", lineNum)
cmd := exec.Command(editor, arg, filePath)
// 重要:连接到终端以支持交互式编辑器
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
return editorFinishedMsg{err: err}
}
}
关键点: 对于 vim/交互式编辑器,必须连接 stdin/stdout/stderr,否则编辑器将无法正常工作。
// 用于组件协调的自定义消息
type (
fileSelectedMsg struct {
file *gitdiff.File
}
diffLoadedMsg struct {
content string
}
)
// 父组件处理协调
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case fileSelectedMsg:
// FileTree 选择了文件,通知 DiffViewer
return m, m.diffViewer.LoadFile(msg.file)
}
// 委托给子组件
var cmd tea.Cmd
m.fileTree, cmd = m.fileTree.Update(msg)
return m, cmd
}
type FocusedPanel int
const (
FocusFileTree FocusedPanel = iota
FocusDiffViewer
)
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == tea.KeyTab {
// 切换焦点
m.focused = (m.focused + 1) % 2
return m, nil
}
// 只有获得焦点的组件处理输入
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}
对于帮助对话框等覆盖层:
type Model struct {
helpDialog *components.HelpDialog
showHelp bool
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// 帮助对话框在可见时拦截输入
if m.showHelp {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = false
return m, nil
}
// 帮助对话框处理所有输入
*m.helpDialog, cmd = m.helpDialog.Update(msg)
return m, cmd
}
// 切换帮助
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = true
return m, nil
}
// 正常输入处理
// ...
}
func (m Model) View() string {
view := m.renderNormal()
if m.showHelp {
// 将帮助覆盖在顶部
return m.helpDialog.View(view)
}
return view
}
// 不良做法:在 View 中修改状态
func (m Model) View() string {
m.offset++ // 切勿在 View 中修改状态!
return m.render()
}
View 必须是纯函数 - 无副作用!
// 不良做法:在 Update 中执行阻塞 I/O
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
content := os.ReadFile("large-file.txt") // 阻塞 UI!
m.content = string(content)
return m, nil
}
对 I/O 使用命令。
// 不良做法:在 Update 中编写 200 行逻辑
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// ... 200 行按键处理代码 ...
}
}
提取到辅助方法:
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
}
}
func (m Model) handleKeyPress(msg tea.KeyPressMsg) (Model, tea.Cmd) {
// 清晰的逻辑在此
}
每周安装量
39
仓库
GitHub 星标数
19
首次出现
2026年2月15日
安全审计
安装于
github-copilot39
codex39
kimi-cli39
gemini-cli39
amp39
opencode39
Best practices for building maintainable, testable TUI components using Bubbletea v2 and the Charm ecosystem, based on the hive diff viewer implementation.
Each component should be in its own file with clear boundaries:
internal/tui/diff/
├── model.go # Top-level compositor that orchestrates sub-components
├── diffviewer.go # Diff content display with scrolling and selection
├── filetree.go # File navigation tree with expand/collapse
├── lineparse.go # Pure function utilities for parsing diff lines
├── delta.go # External tool integration (syntax highlighting)
└── utils.go # Shared utilities
Key principle: Each file should represent ONE component with its own Model, Update, and View methods.
For complex UIs, use a compositor pattern:
// Top-level Model composes sub-components
type Model struct {
fileTree FileTreeModel // Left panel
diffViewer DiffViewerModel // Right panel
focused FocusedPanel // Which component has focus
helpDialog *components.HelpDialog // Modal overlay
showHelp bool // Dialog visibility state
}
// Update delegates to focused component
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}
Benefits:
// 1. Model struct with all state
type ComponentModel struct {
// Data
items []Item
// UI State
selected int
offset int
width int
height int
// Feature flags
iconStyle IconStyle
expanded bool
}
// 2. Constructor with dependencies
func NewComponent(data []Item, cfg *config.Config) ComponentModel {
return ComponentModel{
items: data,
selected: 0,
iconStyle: determineIconStyle(cfg),
}
}
// 3. Update handles messages
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
// 4. View renders output
func (m ComponentModel) View() string {
return m.render()
}
// 5. Helper methods for complex logic
func (m ComponentModel) render() string {
// Rendering logic here
}
Bad:
// State hidden in closures or package variables
var currentSelection int
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
currentSelection++ // Modifying hidden state
}
Good:
// All state explicit in model
type Model struct {
currentSelection int
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.currentSelection++ // Clear, traceable state change
return m, nil
}
type DiffViewerModel struct {
// Immutable data
file *gitdiff.File
content string
lines []string
// Mutable UI state
offset int // Scroll position
cursorLine int // Current line
selectionMode bool // Visual mode active
selectionStart int // Selection anchor
}
Benefits:
For expensive operations like syntax highlighting or external tool calls:
type ComponentModel struct {
cache map[string]*CachedResult
loading bool
}
// 1. Initiate async operation, return immediately
func (m *ComponentModel) SetData(data *Data) tea.Cmd {
filePath := data.Path
// Check cache first
if cached, ok := m.cache[filePath]; ok {
m.content = cached.content
m.lines = cached.lines
return nil
}
// Mark as loading, start async
m.loading = true
return func() tea.Msg {
content, lines := generateContent(data)
return contentGeneratedMsg{filePath, content, lines}
}
}
// 2. Handle completion message
func (m ComponentModel) Update(msg tea.Msg) (ComponentModel, tea.Cmd) {
switch msg := msg.(type) {
case contentGeneratedMsg:
// Cache result
m.cache[msg.filePath] = &CachedResult{
content: msg.content,
lines: msg.lines,
}
// Update display
m.content = msg.content
m.lines = msg.lines
m.loading = false
}
return m, nil
}
Key points:
For tools like delta (syntax highlighting):
// 1. Check availability once at init
func NewDiffViewer(file *gitdiff.File) DiffViewerModel {
deltaAvailable := CheckDeltaAvailable() == nil
return DiffViewerModel{
deltaAvailable: deltaAvailable,
}
}
// 2. Separate pure function for testability
func generateDiffContent(file *gitdiff.File, deltaAvailable bool) (string, []string) {
diff := buildUnifiedDiff(file)
if !deltaAvailable {
return diff, strings.Split(diff, "\n")
}
// Apply syntax highlighting
return applyDelta(diff)
}
// 3. Make it async with proper error handling
func (m *ComponentModel) loadContent(file *gitdiff.File) tea.Cmd {
return func() tea.Msg {
content, lines := generateDiffContent(file, m.deltaAvailable)
return contentReadyMsg{content, lines}
}
}
For vim-style interfaces with normal/visual modes:
type Model struct {
mode Mode // Normal, Visual, Insert
selectionMode bool // Visual mode active
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// Handle mode transitions first
if msg.Code == 'v' && !m.selectionMode {
m.selectionMode = true
m.selectionStart = m.cursorLine
return m, nil
}
if msg.Code == tea.KeyEscape && m.selectionMode {
m.selectionMode = false
return m, nil
}
// Handle mode-specific behavior
if m.selectionMode {
return m.handleVisualMode(msg)
}
return m.handleNormalMode(msg)
}
return m, nil
}
For visual selection (highlighting lines):
type Model struct {
selectionMode bool
selectionStart int // Anchor point
cursorLine int // Active end
}
// Helper to get normalized selection range
func (m Model) SelectionRange() (start, end int, active bool) {
if !m.selectionMode {
return 0, 0, false
}
start = m.selectionStart
end = m.cursorLine
if start > end {
start, end = end, start
}
return start, end, true
}
// Use in rendering
func (m Model) View() string {
start, end, active := m.SelectionRange()
for i, line := range m.lines {
if active && i >= start && i <= end {
line = highlightStyle.Render(line)
}
// ... render line
}
}
For scrollable content with fixed dimensions:
type Model struct {
lines []string
offset int // Top visible line
height int // Viewport height
}
// Calculate visible range
func (m Model) visibleLines() []string {
start := m.offset
end := min(m.offset + m.contentHeight(), len(m.lines))
return m.lines[start:end]
}
// Content height (excluding fixed UI elements)
func (m Model) contentHeight() int {
return m.height - headerHeight - footerHeight
}
// Scroll with cursor tracking
func (m Model) scrollDown() Model {
// Move cursor first
if m.cursorLine < len(m.lines)-1 {
m.cursorLine++
}
// Adjust viewport if cursor moved out of view
visibleBottom := m.offset + m.contentHeight() - 1
if m.cursorLine > visibleBottom {
m.offset++
}
return m
}
Key principle: Cursor moves first, viewport follows to keep cursor visible.
Pattern for jumping to specific line in editor:
func (m Model) openInEditor(filePath string, lineNum int) tea.Cmd {
return func() tea.Msg {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
}
// Format: editor +line file
arg := fmt.Sprintf("+%d", lineNum)
cmd := exec.Command(editor, arg, filePath)
// Important: Connect to terminal for interactive editors
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
return editorFinishedMsg{err: err}
}
}
Critical: For vim/interactive editors, you must connect stdin/stdout/stderr or the editor won't work properly.
// Custom messages for component coordination
type (
fileSelectedMsg struct {
file *gitdiff.File
}
diffLoadedMsg struct {
content string
}
)
// Parent handles coordination
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case fileSelectedMsg:
// FileTree selected a file, tell DiffViewer
return m, m.diffViewer.LoadFile(msg.file)
}
// Delegate to children
var cmd tea.Cmd
m.fileTree, cmd = m.fileTree.Update(msg)
return m, cmd
}
type FocusedPanel int
const (
FocusFileTree FocusedPanel = iota
FocusDiffViewer
)
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == tea.KeyTab {
// Switch focus
m.focused = (m.focused + 1) % 2
return m, nil
}
// Only focused component handles input
switch m.focused {
case FocusFileTree:
m.fileTree, cmd = m.fileTree.Update(msg)
case FocusDiffViewer:
m.diffViewer, cmd = m.diffViewer.Update(msg)
}
return m, cmd
}
For overlays like help dialogs:
type Model struct {
helpDialog *components.HelpDialog
showHelp bool
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// Help dialog intercepts input when visible
if m.showHelp {
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = false
return m, nil
}
// Help dialog handles all input
*m.helpDialog, cmd = m.helpDialog.Update(msg)
return m, cmd
}
// Toggle help
if msg, ok := msg.(tea.KeyPressMsg); ok && msg.Code == '?' {
m.showHelp = true
return m, nil
}
// Normal input handling
// ...
}
func (m Model) View() string {
view := m.renderNormal()
if m.showHelp {
// Overlay help on top
return m.helpDialog.View(view)
}
return view
}
// BAD: State modified in View
func (m Model) View() string {
m.offset++ // NEVER modify state in View!
return m.render()
}
View must be pure - no side effects!
// BAD: Blocking I/O in Update
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
content := os.ReadFile("large-file.txt") // BLOCKS UI!
m.content = string(content)
return m, nil
}
Use commands for I/O.
// BAD: 200 lines of logic in Update
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
// ... 200 lines of key handling ...
}
}
Extract to helper methods:
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m.handleKeyPress(msg)
}
}
func (m Model) handleKeyPress(msg tea.KeyPressMsg) (Model, tea.Cmd) {
// Clear logic here
}
Weekly Installs
39
Repository
GitHub Stars
19
First Seen
Feb 15, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
github-copilot39
codex39
kimi-cli39
gemini-cli39
amp39
opencode39
Lark Calendar CLI 工具:智能日程管理与会议预约自动化命令行解决方案
48,600 周安装
TikTok视频研究分析工具:AI自动识别热门内容钩子与结构
549 周安装
Spring Boot JWT安全认证:Spring Security 6.x与JJWT实现无状态API保护
548 周安装
FinanceReport:AI驱动投资分析报告生成工具,自动创建8-10页PDF金融报告
549 周安装
Qdrant向量搜索引擎:高性能Rust向量数据库,支持RAG、语义搜索与混合过滤
556 周安装
macOS SwiftPM应用打包指南:无需Xcode构建、签名、公证全流程
559 周安装
ClawSec 安全套件 - OpenClaw 技能安全监控与恶意防护工具
550 周安装