build-tui-view
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinese📝 SELF-UPDATING DOCUMENT: This skill automatically updates itself when inaccuracies are discovered or new patterns are learned. Always verify information against the actual codebase and update this file when needed.
📝 自更新文档:本技能会在发现不准确内容或学习到新模式时自动更新。请始终对照实际代码库验证信息,并在需要时更新此文件。
Overview
概述
This skill provides instructions for creating and maintaining Terminal User Interface (TUI) views in the Hatchet CLI using bubbletea and lipgloss. The TUI system uses a modular view architecture where individual views are isolated in separate files within the directory.
views/IMPORTANT: Always start by finding the corresponding view in the frontend application to understand the structure, columns, and API calls.
本技能提供使用bubbletea和lipgloss在Hatchet CLI中创建和维护终端用户界面(TUI)视图的指导说明。TUI系统采用模块化视图架构,各个视图独立存放在目录下的单独文件中。
views/重要提示:请始终先在前端应用中找到对应的视图,以了解其结构、列和API调用。
Self-Updating Skill Instructions
自更新技能说明
CRITICAL - READ FIRST: This skill document is designed to be continuously improved and kept accurate.
关键必读:本技能文档旨在持续改进并保持准确性。
When to Update This Skill
何时更新本技能
You MUST update this skill file in the following situations:
-
Discovering Inaccuracies
- When you find incorrect file paths or directory structures
- When code examples don't compile or don't match actual implementations
- When API signatures have changed
- When referenced files don't exist at specified locations
-
Learning New Patterns
- When implementing a new view and discovering better approaches
- When the user teaches you new conventions or patterns
- When you find reusable patterns that should be documented
- When you discover common pitfalls that should be warned about
-
Finding Missing Information
- When you need information that isn't documented here
- When new components or utilities are added to the codebase
- When new bubbletea/lipgloss patterns are adopted
-
User Corrections
- When the user corrects any information in this document
- When the user provides updated approaches or conventions
- When the user points out outdated information
在以下情况下,您必须更新本技能文件:
-
发现不准确内容
- 当您发现错误的文件路径或目录结构时
- 当代码示例无法编译或与实际实现不匹配时
- 当API签名发生变化时
- 当引用的文件不存在于指定位置时
-
学习到新模式
- 当您实现新视图并发现更优方法时
- 当用户向您传授新约定或模式时
- 当您发现应记录的可复用模式时
- 当您发现应提醒的常见陷阱时
-
发现缺失信息
- 当您需要此处未记录的信息时
- 当代码库中添加了新组件或工具时
- 当采用了新的bubbletea/lipgloss模式时
-
用户修正
- 当用户修正了本文档中的任何信息时
- 当用户提供了更新后的方法或约定时
- 当用户指出过时信息时
How to Update This Skill
如何更新本技能
When updating this skill:
- Verify Before Adding: Always verify paths, code, and API signatures against the actual codebase before adding to this document
- Use Read/Glob/Grep: Check the actual files to ensure accuracy
- Test Code Examples: Ensure code examples compile and follow current patterns
- Be Specific: Include exact file paths, function signatures, and working code examples
- Update Immediately: Make updates as soon as inaccuracies are discovered, not at the end of a session
- Preserve Structure: Maintain the existing document structure and formatting
- Add Context: When adding new sections, explain why the pattern is recommended
更新本技能时:
- 添加前验证:在添加到本文档之前,请始终对照实际代码库验证路径、代码和API签名
- 使用Read/Glob/Grep:检查实际文件以确保准确性
- 测试代码示例:确保代码示例可编译并遵循当前模式
- 保持具体:包含确切的文件路径、函数签名和可运行的代码示例
- 立即更新:发现不准确内容后立即更新,不要等到会话结束
- 保留结构:维护现有文档的结构和格式
- 添加上下文:添加新章节时,说明推荐该模式的原因
Verification Checklist
验证清单
Before using information from this skill, verify:
- File paths exist and are correct
- Code examples match current implementations
- API signatures match the generated REST client
- Reusable components are correctly referenced
- Directory structures are accurate
使用本技能中的信息之前,请验证:
- 文件路径存在且正确
- 代码示例与当前实现匹配
- API签名与生成的REST客户端匹配
- 可复用组件的引用正确
- 目录结构准确
Self-Correction Process
自修正流程
If you discover an inaccuracy while working:
- Immediately note the issue
- Verify the correct information by reading the actual files
- Update this skill document with the correction
- Continue with the user's task using the corrected information
Remember: This skill should be a living document that grows more accurate and comprehensive with each use.
如果您在工作中发现不准确内容:
- 立即记录问题
- 通过读取实际文件验证正确信息
- 更新本技能文档进行修正
- 使用修正后的信息继续完成用户任务
请记住:本技能应是一个活文档,每次使用都要变得更准确、更全面。
Project Context
项目上下文
- Framework: bubbletea (TUI framework)
- Styling: lipgloss (style definitions)
- TUI Command Location:
cmd/hatchet-cli/cli/tui.go - Views Location: directory
cmd/hatchet-cli/cli/tui/ - Theme: Pre-defined Hatchet theme in
cmd/hatchet-cli/cli/internal/styles/styles.go - Frontend Reference: directory
frontend/app/src/pages/main/v1/
Finding Frontend Prior Art
查找前端已有实现
CRITICAL FIRST STEP: Before implementing any TUI view, locate the corresponding frontend view to understand:
- Column structure and names
- API endpoints and query parameters
- Data types and fields used
- Filtering and sorting logic
关键第一步:在实现任何TUI视图之前,请找到对应的前端视图以了解:
- 列结构和名称
- API端点和查询参数
- 使用的数据类型和字段
- 过滤和排序逻辑
Process for Finding Frontend Reference:
查找前端参考的流程:
-
Locate the Frontend Viewbash
# Navigate to frontend pages cd frontend/app/src/pages/main/v1/ # Find views related to your feature (e.g., workflow-runs, tasks, events) ls -la -
Study the Column Definitions
- Look for files like
{feature}-columns.tsx - Note the column keys, titles, and accessors
- Example:
frontend/app/src/pages/main/v1/workflow-runs-v1/components/v1/task-runs-columns.tsx
typescriptexport const TaskRunColumn = { taskName: "Task Name", status: "Status", workflow: "Workflow", createdAt: "Created At", startedAt: "Started At", duration: "Duration", }; - Look for files like
-
Identify the Data Hook
- Look for files in the
use-{feature}.tsxdirectoryhooks/ - These contain the API query logic
- Example:
frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-runs.tsx
- Look for
-
Find the API Query
- Check for the query definition
frontend/app/src/lib/api/queries.ts - Note the endpoint name and parameters
- Example:
typescriptv1WorkflowRuns: { list: (tenant: string, query: V2ListWorkflowRunsQuery) => ({ queryKey: ['v1:workflow-run:list', tenant, query], queryFn: async () => (await api.v1WorkflowRunList(tenant, query)).data, }), } - Check
-
Map to Go REST Client
- The frontend maps to Go's
api.v1WorkflowRunList()client.API().V1WorkflowRunListWithResponse() - Frontend query parameters map to Go struct parameters
- Example mapping:
typescript// Frontend api.v1WorkflowRunList(tenantId, { offset: 0, limit: 100, since: createdAfter, only_tasks: true, }) // Go equivalent client.API().V1WorkflowRunListWithResponse( ctx, client.TenantId(), &rest.V1WorkflowRunListParams{ Offset: int64Ptr(0), Limit: int64Ptr(100), Since: &since, OnlyTasks: true, }, ) - The frontend
-
定位前端视图bash
# 导航到前端页面 cd frontend/app/src/pages/main/v1/ # 查找与您的功能相关的视图(例如workflow-runs、tasks、events) ls -la -
研究列定义
- 查找类似的文件
{feature}-columns.tsx - 记录列键、标题和访问器
- 示例:
frontend/app/src/pages/main/v1/workflow-runs-v1/components/v1/task-runs-columns.tsx
typescriptexport const TaskRunColumn = { taskName: "Task Name", status: "Status", workflow: "Workflow", createdAt: "Created At", startedAt: "Started At", duration: "Duration", }; - 查找类似
-
识别数据Hook
- 查找目录下的
hooks/文件use-{feature}.tsx - 这些文件包含API查询逻辑
- 示例:
frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-runs.tsx
- 查找
-
查找API查询
- 检查中的查询定义
frontend/app/src/lib/api/queries.ts - 记录端点名称和参数
- 示例:
typescriptv1WorkflowRuns: { list: (tenant: string, query: V2ListWorkflowRunsQuery) => ({ queryKey: ['v1:workflow-run:list', tenant, query], queryFn: async () => (await api.v1WorkflowRunList(tenant, query)).data, }), } - 检查
-
映射到Go REST客户端
- 前端的对应Go的
api.v1WorkflowRunList()client.API().V1WorkflowRunListWithResponse() - 前端查询参数映射到Go结构体参数
- 示例映射:
typescript// 前端 api.v1WorkflowRunList(tenantId, { offset: 0, limit: 100, since: createdAfter, only_tasks: true, }) // Go等价实现 client.API().V1WorkflowRunListWithResponse( ctx, client.TenantId(), &rest.V1WorkflowRunListParams{ Offset: int64Ptr(0), Limit: int64Ptr(100), Since: &since, OnlyTasks: true, }, ) - 前端的
Example: Implementing Tasks View from Frontend Reference
示例:从前端参考实现Tasks视图
-
Frontend Structure:
frontend/app/src/pages/main/v1/workflow-runs-v1/- Columns:
task-runs-columns.tsx - Hook:
use-runs.tsx - Table:
runs-table.tsx
- Columns:
-
Extract Column Names:typescript
taskName, status, workflow, createdAt, startedAt, duration; -
Identify API Call:typescript
queries.v1WorkflowRuns.list(tenantId, { offset, limit, statuses, workflow_ids, since, until, only_tasks: true, }); -
Implement in TUI:go
// Create matching columns columns := []table.Column{ {Title: "Task Name", Width: 30}, {Title: "Status", Width: 12}, {Title: "Workflow", Width: 25}, {Title: "Created At", Width: 16}, {Title: "Started At", Width: 16}, {Title: "Duration", Width: 12}, } // Call matching API endpoint response, err := client.API().V1WorkflowRunListWithResponse( ctx, client.TenantId(), &rest.V1WorkflowRunListParams{ Offset: int64Ptr(0), Limit: int64Ptr(100), Since: &since, OnlyTasks: true, }, )
-
前端结构:
frontend/app/src/pages/main/v1/workflow-runs-v1/- 列:
task-runs-columns.tsx - Hook:
use-runs.tsx - 表格:
runs-table.tsx
- 列:
-
提取列名称:typescript
taskName, status, workflow, createdAt, startedAt, duration; -
识别API调用:typescript
queries.v1WorkflowRuns.list(tenantId, { offset, limit, statuses, workflow_ids, since, until, only_tasks: true, }); -
在TUI中实现:go
// 创建匹配的列 columns := []table.Column{ {Title: "Task Name", Width: 30}, {Title: "Status", Width: 12}, {Title: "Workflow", Width: 25}, {Title: "Created At", Width: 16}, {Title: "Started At", Width: 16}, {Title: "Duration", Width: 12}, } // 调用匹配的API端点 response, err := client.API().V1WorkflowRunListWithResponse( ctx, client.TenantId(), &rest.V1WorkflowRunListParams{ Offset: int64Ptr(0), Limit: int64Ptr(100), Since: &since, OnlyTasks: true, }, )
Reusable Components (CRITICAL - READ FIRST)
可复用组件(关键必读)
IMPORTANT: All TUI views MUST use the standardized reusable components defined in to ensure consistency across the application. DO NOT copy-paste header/footer styling code.
view.go重要提示:所有TUI视图必须使用中定义的标准化可复用组件,以确保应用的一致性。请勿复制粘贴页眉/页脚样式代码。
view.goHeader Component
页眉组件
CRITICAL: ALL headers throughout the TUI use the magenta highlight color () for the title to provide consistent visual emphasis across all views (primary views, detail views, modals, etc.).
styles.HighlightColor关键提示:所有TUI中的页眉都使用洋红色高亮色()显示标题,以在所有视图(主视图、详情视图、模态框等)中提供一致的视觉强调。
styles.HighlightColorFor Detail Views and Modals
详情视图和模态框
Always use for detail views, modals, and secondary screens:
RenderHeader()go
header := RenderHeader("Workflow Details", v.Ctx.ProfileName, v.Width)
header := RenderHeader("Task Details", v.Ctx.ProfileName, v.Width)
header := RenderHeader("Filter Tasks", v.Ctx.ProfileName, v.Width)详情视图、模态框和二级屏幕请始终使用:
RenderHeader()go
header := RenderHeader("Workflow Details", v.Ctx.ProfileName, v.Width)
header := RenderHeader("Task Details", v.Ctx.ProfileName, v.Width)
header := RenderHeader("Filter Tasks", v.Ctx.ProfileName, v.Width)For Primary Views
主视图
Use for primary/list views:
RenderHeaderWithViewIndicator()go
// For primary list views - shows just the view name, no repetitive "Hatchet Workflows [Workflows]"
header := RenderHeaderWithViewIndicator("Runs", v.Ctx.ProfileName, v.Width)
header := RenderHeaderWithViewIndicator("Workflows", v.Ctx.ProfileName, v.Width)This function renders just the view name (e.g., "Runs" or "Workflows") in the highlight color, keeping it simple and non-repetitive.
Features of both header functions:
- Title rendered in magenta highlight color () - consistent across ALL views
styles.HighlightColor - Includes the logo (text-based: "HATCHET TUI") on the right
- Shows profile name
- Bordered bottom edge
- Responsive to terminal width
❌ NEVER do this:
go
// Bad: Copy-pasting header styles
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
BorderStyle(lipgloss.NormalBorder()).
// ... more styling
header := headerStyle.Render(fmt.Sprintf("My View - Profile: %s", profile))
// Bad: Calling RenderHeaderWithLogo directly (bypasses highlight color)
header := RenderHeaderWithLogo(fmt.Sprintf("My View - Profile: %s", profile), v.Width)✅ ALWAYS do this:
go
// Good: Use the reusable component for detail views
header := RenderHeader("Task Details", v.Ctx.ProfileName, v.Width)
// Good: Use the view indicator variant for primary views
header := RenderHeaderWithViewIndicator("Runs", v.Ctx.ProfileName, v.Width)主/列表视图请使用:
RenderHeaderWithViewIndicator()go
// 主列表视图 - 仅显示视图名称,避免重复的"Hatchet Workflows [Workflows]"
header := RenderHeaderWithViewIndicator("Runs", v.Ctx.ProfileName, v.Width)
header := RenderHeaderWithViewIndicator("Workflows", v.Ctx.ProfileName, v.Width)该函数仅以高亮色显示视图名称(例如“Runs”或“Workflows”),保持简洁且无重复。
两种页眉函数的特性:
- 标题以洋红色高亮色()显示 - 所有视图一致
styles.HighlightColor - 右侧包含logo(文本形式:"HATCHET TUI")
- 显示配置文件名称
- 底部带边框
- 响应终端宽度
❌ 请勿这样做:
go
// 错误:复制粘贴页眉样式
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
BorderStyle(lipgloss.NormalBorder()).
// ... 更多样式
header := headerStyle.Render(fmt.Sprintf("My View - Profile: %s", profile))
// 错误:直接调用RenderHeaderWithLogo(绕过高亮色)
header := RenderHeaderWithLogo(fmt.Sprintf("My View - Profile: %s", profile), v.Width)✅ 请始终这样做:
go
// 正确:为详情视图使用可复用组件
header := RenderHeader("Task Details", v.Ctx.ProfileName, v.Width)
// 正确:为主视图使用带视图指示器的变体
header := RenderHeaderWithViewIndicator("Runs", v.Ctx.ProfileName, v.Width)Instructions Component
说明组件
Use to display contextual help text:
RenderInstructions()go
instructions := RenderInstructions(
"Your instructions here • Use bullets to separate items",
v.Width,
)Features:
- Muted color styling for reduced visual noise
- Automatically handles width constraints
- Consistent padding
- Uses bullet separators (•)
使用显示上下文帮助文本:
RenderInstructions()go
instructions := RenderInstructions(
"您的说明内容 • 使用项目符号分隔条目",
v.Width,
)特性:
- 采用柔和颜色样式,减少视觉干扰
- 自动处理宽度限制
- 一致的内边距
- 使用项目符号分隔(•)
Footer Component
页脚组件
Always use for navigation/control hints:
RenderFooter()go
footer := RenderFooter([]string{
"↑/↓: Navigate",
"Enter: Select",
"Esc: Cancel",
"q: Quit",
}, v.Width)Features:
- Consistent styling with top border
- Automatically joins control items with bullets (•)
- Muted color for non-intrusive display
- Responsive to terminal width
导航/控制提示请始终使用:
RenderFooter()go
footer := RenderFooter([]string{
"↑/↓: 导航",
"Enter: 选择",
"Esc: 取消",
"q: 退出",
}, v.Width)特性:
- 带顶部边框的一致样式
- 自动使用项目符号(•)连接控制项
- 柔和颜色,不引人注目
- 响应终端宽度
Standard View Structure
标准视图结构
Every view should follow this consistent structure:
go
func (v *YourView) View() string {
var b strings.Builder
// 1. Header (always) - USE REUSABLE COMPONENT
header := RenderHeader("View Title", v.Ctx.ProfileName, v.Width)
b.WriteString(header)
b.WriteString("\n\n")
// 2. Instructions (when helpful) - USE REUSABLE COMPONENT
instructions := RenderInstructions("Your instructions", v.Width)
b.WriteString(instructions)
b.WriteString("\n\n")
// 3. Main content
// ... your view-specific content ...
// 4. Footer (always) - USE REUSABLE COMPONENT
footer := RenderFooter([]string{
"control1: Action1",
"control2: Action2",
}, v.Width)
b.WriteString(footer)
return b.String()
}每个视图都应遵循以下一致的结构:
go
func (v *YourView) View() string {
var b strings.Builder
// 1. 页眉(必须)- 使用可复用组件
header := RenderHeader("View Title", v.Ctx.ProfileName, v.Width)
b.WriteString(header)
b.WriteString("\n\n")
// 2. 说明(如有帮助)- 使用可复用组件
instructions := RenderInstructions("您的说明内容", v.Width)
b.WriteString(instructions)
b.WriteString("\n\n")
// 3. 主要内容
// ... 您的视图特定内容 ...
// 4. 页脚(必须)- 使用可复用组件
footer := RenderFooter([]string{
"control1: Action1",
"control2: Action2",
}, v.Width)
b.WriteString(footer)
return b.String()
}Architecture
架构
Root TUI Model (tui.go
)
tui.go根TUI模型(tui.go
)
tui.goThe root TUI command is responsible for:
- Profile selection and validation
- Initializing the Hatchet client
- Creating the view context
- Managing the current view
- Delegating updates to views
根TUI命令负责:
- 配置文件选择和验证
- 初始化Hatchet客户端
- 创建视图上下文
- 管理当前视图
- 将更新委托给视图
View System (views/
directory)
views/视图系统(views/
目录)
views/Each view is a separate file that implements the interface:
View- - Base view interface, context, and reusable components
view.go - - Individual view implementations (e.g.,
{viewname}.go)tasks.go
每个视图都是一个单独的文件,实现接口:
View- - 基础视图接口、上下文和可复用组件
view.go - - 各个视图的实现(例如
{viewname}.go)tasks.go
Core Principles
核心原则
1. File Structure
1. 文件结构
TUI Command File
TUI命令文件
- File:
cmd/hatchet-cli/cli/tui.go - Purpose: Command setup, profile selection, client initialization, view management
- 文件:
cmd/hatchet-cli/cli/tui.go - 用途:命令设置、配置文件选择、客户端初始化、视图管理
View Files
视图文件
- Location:
cmd/hatchet-cli/cli/tui/ - Files:
- - View interface and base types
view.go - - Individual view implementations
{viewname}.go
- 位置:
cmd/hatchet-cli/cli/tui/ - 文件:
- - 视图接口和基础类型
view.go - - 各个视图的实现
{viewname}.go
2. View Interface
2. 视图接口
All views must implement this interface (defined in ):
views/view.gogo
package views
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/hatchet-dev/hatchet/pkg/client"
)
// ViewContext contains the shared context passed to all views
type ViewContext struct {
// Profile name for display
ProfileName string
// Hatchet client for API calls
Client client.Client
// Terminal dimensions
Width int
Height int
}
// View represents a TUI view component
type View interface {
// Init initializes the view and returns any initial commands
Init() tea.Cmd
// Update handles messages and updates the view state
Update(msg tea.Msg) (View, tea.Cmd)
// View renders the view to a string
View() string
// SetSize updates the view dimensions
SetSize(width, height int)
}所有视图必须实现此接口(定义在中):
views/view.gogo
package views
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/hatchet-dev/hatchet/pkg/client"
)
// ViewContext包含传递给所有视图的共享上下文
type ViewContext struct {
// 用于显示的配置文件名称
ProfileName string
// 用于API调用的Hatchet客户端
Client client.Client
// 终端尺寸
Width int
Height int
}
// View表示一个TUI视图组件
type View interface {
// Init初始化视图并返回任何初始命令
Init() tea.Cmd
// Update处理消息并更新视图状态
Update(msg tea.Msg) (View, tea.Cmd)
// View将视图渲染为字符串
View() string
// SetSize更新视图尺寸
SetSize(width, height int)
}3. Base Model Pattern
3. 基础模型模式
Use for common view fields:
BaseModelgo
// BaseModel contains common fields for all views
type BaseModel struct {
Ctx ViewContext
Width int
Height int
Err error
}
// Your view embeds BaseModel
type YourView struct {
BaseModel
// Your view-specific fields
table table.Model
items []YourDataType
}使用存储通用视图字段:
BaseModelgo
// BaseModel包含所有视图的通用字段
type BaseModel struct {
Ctx ViewContext
Width int
Height int
Err error
}
// 您的视图嵌入BaseModel
type YourView struct {
BaseModel
// 您的视图特定字段
table table.Model
items []YourDataType
}4. Creating a New View
4. 创建新视图
Step 1: Create View File
步骤1:创建视图文件
Create :
cmd/hatchet-cli/cli/tui/{viewname}.gogo
package views
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles"
"github.com/hatchet-dev/hatchet/pkg/client/rest"
)
type YourView struct {
BaseModel
// View-specific fields
}
// NewYourView creates a new instance of your view
func NewYourView(ctx ViewContext) *YourView {
v := &YourView{
BaseModel: BaseModel{
Ctx: ctx,
},
}
// Initialize view components
return v
}
func (v *YourView) Init() tea.Cmd {
return nil
}
func (v *YourView) Update(msg tea.Msg) (View, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
return v, nil
case tea.KeyMsg:
switch msg.String() {
case "r":
// Refresh logic
return v, nil
}
}
// Update sub-components
return v, cmd
}
func (v *YourView) View() string {
if v.Width == 0 {
return "Initializing..."
}
// Build your view
return "Your view content"
}
func (v *YourView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
// Update view-specific components
}创建:
cmd/hatchet-cli/cli/tui/{viewname}.gogo
package views
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles"
"github.com/hatchet-dev/hatchet/pkg/client/rest"
)
type YourView struct {
BaseModel
// 视图特定字段
}
// NewYourView创建您的视图的新实例
func NewYourView(ctx ViewContext) *YourView {
v := &YourView{
BaseModel: BaseModel{
Ctx: ctx,
},
}
// 初始化视图组件
return v
}
func (v *YourView) Init() tea.Cmd {
return nil
}
func (v *YourView) Update(msg tea.Msg) (View, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
return v, nil
case tea.KeyMsg:
switch msg.String() {
case "r":
// 刷新逻辑
return v, nil
}
}
// 更新子组件
return v, cmd
}
func (v *YourView) View() string {
if v.Width == 0 {
return "初始化中..."
}
// 构建您的视图
return "您的视图内容"
}
func (v *YourView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
// 更新视图特定组件
}Step 2: Use View in TUI
步骤2:在TUI中使用视图
The root TUI model manages views:
go
// In tui.go
func newTUIModel(profileName string, hatchetClient client.Client) tuiModel {
ctx := views.ViewContext{
ProfileName: profileName,
Client: hatchetClient,
}
// Initialize with your view
currentView := views.NewYourView(ctx)
return tuiModel{
currentView: currentView,
}
}根TUI模型管理视图:
go
// 在tui.go中
func newTUIModel(profileName string, hatchetClient client.Client) tuiModel {
ctx := views.ViewContext{
ProfileName: profileName,
Client: hatchetClient,
}
// 使用您的视图初始化
currentView := views.NewYourView(ctx)
return tuiModel{
currentView: currentView,
}
}5. Client Initialization Pattern
5. 客户端初始化模式
Always initialize the Hatchet client in :
tui.gogo
import (
"github.com/rs/zerolog"
"github.com/hatchet-dev/hatchet/pkg/client"
)
// In the cobra command Run function
profile, err := cli.GetProfile(selectedProfile)
if err != nil {
cli.Logger.Fatalf("could not get profile '%s': %v", selectedProfile, err)
}
// Initialize Hatchet client
nopLogger := zerolog.Nop()
hatchetClient, err := client.New(
client.WithToken(profile.Token),
client.WithLogger(&nopLogger),
)
if err != nil {
cli.Logger.Fatalf("could not create Hatchet client: %v", err)
}请始终在中初始化Hatchet客户端:
tui.gogo
import (
"github.com/rs/zerolog"
"github.com/hatchet-dev/hatchet/pkg/client"
)
// 在cobra命令的Run函数中
profile, err := cli.GetProfile(selectedProfile)
if err != nil {
cli.Logger.Fatalf("无法获取配置文件'%s': %v", selectedProfile, err)
}
// 初始化Hatchet客户端
nopLogger := zerolog.Nop()
hatchetClient, err := client.New(
client.WithToken(profile.Token),
client.WithLogger(&nopLogger),
)
if err != nil {
cli.Logger.Fatalf("无法创建Hatchet客户端: %v", err)
}6. Accessing the Client in Views
6. 在视图中访问客户端
The Hatchet client is available through the view context:
go
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
// Access the client
client := v.Ctx.Client
// Make API calls
// response, err := client.API().SomeEndpoint(...)
return yourDataMsg{
data: data,
err: err,
}
}
}Hatchet客户端可通过视图上下文访问:
go
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
// 访问客户端
client := v.Ctx.Client
// 发起API调用
// response, err := client.API().SomeEndpoint(...)
return yourDataMsg{
data: data,
err: err,
}
}
}7. Hatchet Theme Integration
7. Hatchet主题集成
CRITICAL: NEVER hardcode colors or styles in view files. Always use the pre-defined Hatchet theme colors and utilities from .
cmd/hatchet-cli/cli/internal/styles关键提示:请勿在视图文件中硬编码颜色或样式。请始终使用中预定义的Hatchet主题颜色和工具。
cmd/hatchet-cli/cli/internal/stylesAvailable Theme Colors
可用主题颜色
go
import "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles"
// Primary theme colors:
// - styles.AccentColor
// - styles.PrimaryColor
// - styles.SuccessColor
// - styles.HighlightColor
// - styles.MutedColor
// - styles.Blue, styles.Cyan, styles.Magenta
// Status colors (matching frontend badge variants):
// - styles.StatusSuccessColor / styles.StatusSuccessBg
// - styles.StatusFailedColor / styles.StatusFailedBg
// - styles.StatusInProgressColor / styles.StatusInProgressBg
// - styles.StatusQueuedColor / styles.StatusQueuedBg
// - styles.StatusCancelledColor / styles.StatusCancelledBg
// - styles.ErrorColor
// Available styles:
// - styles.H1, styles.H2
// - styles.Bold, styles.Italic
// - styles.Primary, styles.Accent, styles.Success
// - styles.Code
// - styles.Box, styles.InfoBox, styles.SuccessBoxgo
import "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles"
// 主要主题颜色:
// - styles.AccentColor
// - styles.PrimaryColor
// - styles.SuccessColor
// - styles.HighlightColor
// - styles.MutedColor
// - styles.Blue, styles.Cyan, styles.Magenta
// 状态颜色(与前端徽章变体匹配):
// - styles.StatusSuccessColor / styles.StatusSuccessBg
// - styles.StatusFailedColor / styles.StatusFailedBg
// - styles.StatusInProgressColor / styles.StatusInProgressBg
// - styles.StatusQueuedColor / styles.StatusQueuedBg
// - styles.StatusCancelledColor / styles.StatusCancelledBg
// - styles.ErrorColor
// 可用样式:
// - styles.H1, styles.H2
// - styles.Bold, styles.Italic
// - styles.Primary, styles.Accent, styles.Success
// - styles.Code
// - styles.Box, styles.InfoBox, styles.SuccessBoxStatus Rendering
状态渲染
Per-Cell Coloring in Tables: Use the custom wrapper to enable per-cell styling.
TableWithStyleFuncFor status rendering in tables:
go
// Create table with StyleFunc support
t := NewTableWithStyleFunc(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(20),
)
// Set StyleFunc for per-cell styling
t.SetStyleFunc(func(row, col int) lipgloss.Style {
// Column 1 is the status column
if col == 1 && row < len(v.tasks) {
statusStyle := styles.GetV1TaskStatusStyle(v.tasks[row].Status)
return lipgloss.NewStyle().Foreground(statusStyle.Foreground)
}
return lipgloss.NewStyle()
})
// In updateTableRows, use plain text (StyleFunc applies colors)
statusStyle := styles.GetV1TaskStatusStyle(task.Status)
status := statusStyle.Text // "Succeeded", "Failed", etc.For non-table contexts (headers, footers, standalone text):
go
// Render V1TaskStatus with proper colors
status := styles.RenderV1TaskStatus(task.Status)
// Render error messages
errorMsg := styles.RenderError(fmt.Sprintf("Error: %v", err))Why custom TableWithStyleFunc?
- Standard bubbles table doesn't support per-cell or per-column styling
- wraps bubbles table and adds StyleFunc support
TableWithStyleFunc - StyleFunc allows dynamic cell styling based on row/column index
- Located in
cmd/hatchet-cli/cli/tui/table_custom.go - Maintains bubbles table interactivity (cursor, selection, keyboard nav)
表格中的单元格着色:使用自定义包装器启用单元格级样式。
TableWithStyleFunc表格中的状态渲染:
go
// 创建支持StyleFunc的表格
t := NewTableWithStyleFunc(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(20),
)
// 设置StyleFunc进行单元格级样式设置
t.SetStyleFunc(func(row, col int) lipgloss.Style {
// 第1列是状态列
if col == 1 && row < len(v.tasks) {
statusStyle := styles.GetV1TaskStatusStyle(v.tasks[row].Status)
return lipgloss.NewStyle().Foreground(statusStyle.Foreground)
}
return lipgloss.NewStyle()
})
// 在updateTableRows中使用纯文本(StyleFunc应用颜色)
statusStyle := styles.GetV1TaskStatusStyle(task.Status)
status := statusStyle.Text // "Succeeded", "Failed"等非表格上下文(页眉、页脚、独立文本):
go
// 使用正确的颜色渲染V1TaskStatus
status := styles.RenderV1TaskStatus(task.Status)
// 渲染错误消息
errorMsg := styles.RenderError(fmt.Sprintf("Error: %v", err))为什么使用自定义TableWithStyleFunc?
- 标准bubbles表格不支持单元格级或列级样式
- 包装bubbles表格并添加StyleFunc支持
TableWithStyleFunc - StyleFunc允许基于行/列索引进行动态单元格样式设置
- 位于
cmd/hatchet-cli/cli/tui/table_custom.go - 保留bubbles表格的交互性(光标、选择、键盘导航)
Table Styling
表格样式
go
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(styles.AccentColor).
BorderBottom(true).
Bold(true).
Foreground(styles.AccentColor)
s.Selected = s.Selected.
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true)Note: Use even for basic colors like white/black to support light/dark terminals.
lipgloss.AdaptiveColorgo
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(styles.AccentColor).
BorderBottom(true).
Bold(true).
Foreground(styles.AccentColor)
s.Selected = s.Selected.
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true)注意:即使是白色/黑色等基础颜色,也请使用以支持亮色/暗色终端。
lipgloss.AdaptiveColorAdding New Status Colors
添加新状态颜色
If you need to add new status colors:
- Add the color constants to
cmd/hatchet-cli/cli/internal/styles/styles.go - Create or update the utility function in
cmd/hatchet-cli/cli/internal/styles/status.go - Reference the frontend badge variants in for color values
frontend/app/src/components/v1/ui/badge.tsx - Use adaptive colors for light/dark terminal support
如果您需要添加新的状态颜色:
- 将颜色常量添加到
cmd/hatchet-cli/cli/internal/styles/styles.go - 在中创建或更新工具函数
cmd/hatchet-cli/cli/internal/styles/status.go - 参考中的前端徽章变体获取颜色值
frontend/app/src/components/v1/ui/badge.tsx - 使用自适应颜色以支持亮色/暗色终端
8. Standard Keyboard Controls
8. 标准键盘控制
Use consistent key mappings across all views to provide a predictable user experience.
在所有视图中使用一致的按键映射,以提供可预测的用户体验。
Global Controls (handled in tui.go)
全局控制(在tui.go中处理)
- or
q: Quit the TUIctrl+c
- 或
q:退出TUIctrl+c
View-Specific Controls
视图特定控制
Implement these in individual views:
- Navigation: or arrow keys for list navigation
↑/↓ - Selection: to select/confirm
Enter - Tab Navigation: /
Tabfor form fieldsShift+Tab - Cancel: to go back/cancel
Esc - Refresh: to manually refresh data
r - Filter: to open filter modal (where applicable)
f - Debug: to toggle debug view (see Debug Logging section)
d - Clear: to clear debug logs (when in debug view)
c - Tab Views: ,
1,2, etc. or3/tabfor switching tabsshift+tab
Important: Always document keyboard controls in the footer using
RenderFooter()在各个视图中实现以下控制:
- 导航:或箭头键用于列表导航
↑/↓ - 选择:用于选择/确认
Enter - Tab导航:/
Tab用于表单字段Shift+Tab - 取消:用于返回/取消
Esc - 刷新:用于手动刷新数据
r - 过滤:用于打开过滤模态框(如适用)
f - 调试:用于切换调试视图(请参阅调试日志部分)
d - 清除:用于清除调试日志(在调试视图中时)
c - Tab视图:、
1、2等或3/tab用于切换标签页shift+tab
重要提示:请始终使用在页脚中记录键盘控制
RenderFooter()9. Layout Components
9. 布局组件
CRITICAL: Use the reusable components from for headers, instructions, and footers. See "Reusable Components" section above.
view.go关键提示:页眉、说明和页脚请使用中的可复用组件。请参阅上方的“可复用组件”部分。
view.goHeader
页眉
✅ Use the reusable component:
go
header := RenderHeader("View Title", v.Ctx.ProfileName, v.Width)❌ DO NOT manually create headers:
go
// Bad: Don't do this
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
// ... (this violates DRY principle)✅ 使用可复用组件:
go
header := RenderHeader("View Title", v.Ctx.ProfileName, v.Width)❌ 请勿手动创建页眉:
go
// 错误:请勿这样做
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
// ...(违反DRY原则)Footer
页脚
✅ Use the reusable component:
go
footer := RenderFooter([]string{
"↑/↓: Navigate",
"r: Refresh",
"q: Quit",
}, v.Width)❌ DO NOT manually create footers:
go
// Bad: Don't do this
footerStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
// ... (this violates DRY principle)✅ 使用可复用组件:
go
footer := RenderFooter([]string{
"↑/↓: 导航",
"r: 刷新",
"q: 退出",
}, v.Width)❌ 请勿手动创建页脚:
go
// 错误:请勿这样做
footerStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
// ...(违反DRY原则)Instructions
说明
✅ Use the reusable component:
go
instructions := RenderInstructions("Your helpful instructions here", v.Width)✅ 使用可复用组件:
go
instructions := RenderInstructions("您的帮助说明内容", v.Width)Stats Bar
统计栏
Custom stats bars are fine for view-specific metrics:
go
statsStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
Padding(0, 1)
stats := statsStyle.Render(fmt.Sprintf(
"Total: %d | Status1: %d | Status2: %d",
total, status1Count, status2Count,
))自定义统计栏适用于视图特定指标:
go
statsStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
Padding(0, 1)
stats := statsStyle.Render(fmt.Sprintf(
"总计: %d | 状态1: %d | 状态2: %d",
total, status1Count, status2Count,
))10. Data Integration
10. 数据集成
REST API Types
REST API类型
Use generated REST types from:
go
import "github.com/hatchet-dev/hatchet/pkg/client/rest"Common types:
rest.V1TaskSummaryrest.V1TaskSummaryListrest.V1WorkflowRunrest.V1WorkflowRunDetailsrest.Workerrest.WorkerRuntimeInforest.Workflowrest.APIResourceMeta
使用生成的REST类型:
go
import "github.com/hatchet-dev/hatchet/pkg/client/rest"常见类型:
rest.V1TaskSummaryrest.V1TaskSummaryListrest.V1WorkflowRunrest.V1WorkflowRunDetailsrest.Workerrest.WorkerRuntimeInforest.Workflowrest.APIResourceMeta
Async Data Fetching Pattern
异步数据获取模式
go
// Define custom message types in your view file
type yourDataMsg struct {
items []YourDataType
err error
}
// Create fetch command
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
// Use v.Ctx.Client to make API calls
// Return yourDataMsg
}
}
// Handle in Update
case yourDataMsg:
v.loading = false
if msg.err != nil {
v.HandleError(msg.err)
} else {
v.items = msg.items
v.ClearError()
}go
// 在您的视图文件中定义自定义消息类型
type yourDataMsg struct {
items []YourDataType
err error
}
// 创建获取命令
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
// 使用v.Ctx.Client发起API调用
// 返回yourDataMsg
}
}
// 在Update中处理
case yourDataMsg:
v.loading = false
if msg.err != nil {
v.HandleError(msg.err)
} else {
v.items = msg.items
v.ClearError()
}11. Modal Views
11. 模态视图
When creating modal overlays (like filter forms or confirmation dialogs):
- Still show the header with updated title using
RenderHeader() - Show instructions specific to the modal interaction using
RenderInstructions() - Show the modal content
- Show a footer with modal-specific controls using
RenderFooter()
Example Modal Structure:
go
func (v *TasksView) renderFilterModal() string {
var b strings.Builder
// 1. Header - USE REUSABLE COMPONENT
header := RenderHeader("Filter Tasks", v.Ctx.ProfileName, v.Width)
b.WriteString(header)
b.WriteString("\n\n")
// 2. Instructions - USE REUSABLE COMPONENT
instructions := RenderInstructions("Configure filters and press Enter to apply", v.Width)
b.WriteString(instructions)
b.WriteString("\n\n")
// 3. Modal content (form, etc.)
b.WriteString(v.filterForm.View())
b.WriteString("\n")
// 4. Footer - USE REUSABLE COMPONENT
footer := RenderFooter([]string{"Enter: Apply", "Esc: Cancel"}, v.Width)
b.WriteString(footer)
return b.String()
}Important: Modals should maintain the same visual structure as regular views (header, instructions, content, footer) for consistency.
创建模态覆盖层(如过滤表单或确认对话框)时:
- 仍需使用显示更新后的页眉
RenderHeader() - 使用显示特定于模态交互的说明
RenderInstructions() - 显示模态内容
- 使用显示特定于模态的控制项页脚
RenderFooter()
示例模态结构:
go
func (v *TasksView) renderFilterModal() string {
var b strings.Builder
// 1. 页眉 - 使用可复用组件
header := RenderHeader("过滤任务", v.Ctx.ProfileName, v.Width)
b.WriteString(header)
b.WriteString("\n\n")
// 2. 说明 - 使用可复用组件
instructions := RenderInstructions("配置过滤器并按Enter应用", v.Width)
b.WriteString(instructions)
b.WriteString("\n\n")
// 3. 模态内容(表单等)
b.WriteString(v.filterForm.View())
b.WriteString("\n")
// 4. 页脚 - 使用可复用组件
footer := RenderFooter([]string{"Enter: 应用", "Esc: 取消"}, v.Width)
b.WriteString(footer)
return b.String()
}重要提示:模态框应保持与常规视图相同的视觉结构(页眉、说明、内容、页脚)以确保一致性。
12. Form Integration
12. 表单集成
When using forms in views:
huh- Set the Hatchet theme:
.WithTheme(styles.HatchetTheme()) - Integrate forms directly into the main tea.Program (don't run separate programs)
- Handle form completion by checking
form.State == huh.StateCompleted - Pass ALL messages to the form when it's active (not just key messages)
Example:
go
import "github.com/charmbracelet/huh"
// In Update()
if v.showingFilter && v.filterForm != nil {
// Pass ALL messages to form when active
form, cmd := v.filterForm.Update(msg)
v.filterForm = form.(*huh.Form)
// Check if form completed
if v.filterForm.State == huh.StateCompleted {
v.showingFilter = false
// Process form values
}
return v, cmd
}在视图中使用表单时:
huh- 设置Hatchet主题:
.WithTheme(styles.HatchetTheme()) - 将表单直接集成到主tea.Program中(不要运行单独的程序)
- 通过检查处理表单完成事件
form.State == huh.StateCompleted - 表单激活时传递所有消息给表单(不仅仅是按键消息)
示例:
go
import "github.com/charmbracelet/huh"
// 在Update()中
if v.showingFilter && v.filterForm != nil {
// 表单激活时传递所有消息
form, cmd := v.filterForm.Update(msg)
v.filterForm = form.(*huh.Form)
// 检查表单是否完成
if v.filterForm.State == huh.StateCompleted {
v.showingFilter = false
// 处理表单值
}
return v, cmd
}13. Table Component
13. 表格组件
Using :
github.com/charmbracelet/bubbles/tablego
import "github.com/charmbracelet/bubbles/table"
// Define columns
columns := []table.Column{
{Title: "Column1", Width: 20},
{Title: "Column2", Width: 30},
}
// Create table
t := table.New(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(20),
)
// Apply Hatchet styles
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(styles.AccentColor).
BorderBottom(true).
Bold(true).
Foreground(styles.AccentColor)
s.Selected = s.Selected.
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true)
t.SetStyles(s)
// Update rows
rows := make([]table.Row, len(items))
for i, item := range items {
rows[i] = table.Row{item.Field1, item.Field2}
}
t.SetRows(rows)使用:
github.com/charmbracelet/bubbles/tablego
import "github.com/charmbracelet/bubbles/table"
// 定义列
columns := []table.Column{
{Title: "Column1", Width: 20},
{Title: "Column2", Width: 30},
}
// 创建表格
t := table.New(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(20),
)
// 应用Hatchet样式
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(styles.AccentColor).
BorderBottom(true).
Bold(true).
Foreground(styles.AccentColor)
s.Selected = s.Selected.
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true)
t.SetStyles(s)
// 更新行
rows := make([]table.Row, len(items))
for i, item := range items {
rows[i] = table.Row{item.Field1, item.Field2}
}
t.SetRows(rows)14. Table Height Calculations and Layout Optimization
14. 表格高度计算和布局优化
CRITICAL: Proper table height calculation is essential for optimal use of terminal space. Different view types require different calculations based on the UI elements displayed above and below the table.
关键提示:正确的表格高度计算对于优化终端空间使用至关重要。不同的视图类型需要根据表格上下显示的UI元素进行不同的计算。
Standard Height Calculations by View Type
按视图类型划分的标准高度计算
Primary List Views (e.g., runs_list, workflows):
- Calculation:
height - 12 - Accounts for: header (3 lines), stats bar (2 lines), spacing (2 lines), footer (2 lines), buffer (3 lines)
go
func (v *RunsListView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
v.table.SetHeight(msg.Height - 12) // Primary view calculation
return v, nil
}
// ...
}
func (v *RunsListView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
if height > 12 {
v.table.SetHeight(height - 12)
}
}Detail Views with Additional Info Sections (e.g., workflow_details with workflow info + runs table):
- Calculation: (or adjust based on info section size)
height - 16 - Accounts for: header (3 lines), info section (4 lines), section header (2 lines), spacing (2 lines), footer (2 lines), buffer (3 lines)
go
func (v *WorkflowDetailsView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
v.table.SetHeight(msg.Height - 16) // Detail view with extra info
return v, nil
}
// ...
}
func (v *WorkflowDetailsView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
if height > 16 {
v.table.SetHeight(height - 16)
}
}主列表视图(例如runs_list、workflows):
- 计算方式:
height - 12 - 包含:页眉(3行)、统计栏(2行)、间距(2行)、页脚(2行)、缓冲区(3行)
go
func (v *RunsListView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
v.table.SetHeight(msg.Height - 12) // 主视图计算方式
return v, nil
}
// ...
}
func (v *RunsListView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
if height > 12 {
v.table.SetHeight(height - 12)
}
}带有额外信息区域的详情视图(例如包含工作流信息+运行表格的workflow_details):
- 计算方式:(或根据信息区域大小调整)
height - 16 - 包含:页眉(3行)、信息区域(4行)、区域页眉(2行)、间距(2行)、页脚(2行)、缓冲区(3行)
go
func (v *WorkflowDetailsView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
v.table.SetHeight(msg.Height - 16) // 带有额外信息的详情视图计算方式
return v, nil
}
// ...
}
func (v *WorkflowDetailsView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
if height > 16 {
v.table.SetHeight(height - 16)
}
}Guidelines for Height Calculation
高度计算指南
- Count Your UI Elements: List all elements that appear above and below the table
- Estimate Line Counts:
- Header: ~3 lines (with spacing)
- Stats bar: ~2 lines (with spacing)
- Section headers: ~2 lines each
- Info sections: ~3-5 lines depending on content
- Footer: ~2 lines (with spacing)
- Buffer: ~2-3 lines for safety
- Test at Different Sizes: Verify the table has adequate space at minimum terminal size (80x24)
- Iterate if Needed: If the table feels cramped, reduce the height offset by 2-4 lines
Common Mistake: Using the same height calculation for all views without accounting for additional UI elements.
❌ Wrong:
go
// Detail view with extra info section but using primary view calculation
v.table.SetHeight(msg.Height - 12) // Table will be too large, overlapping footer✅ Correct:
go
// Adjust calculation based on actual UI elements in the view
v.table.SetHeight(msg.Height - 16) // Accounts for extra info section- 统计您的UI元素:列出表格上下显示的所有元素
- 估算行数:
- 页眉:约3行(含间距)
- 统计栏:约2行(含间距)
- 区域页眉:每个约2行
- 信息区域:约3-5行(取决于内容)
- 页脚:约2行(含间距)
- 缓冲区:约2-3行(安全起见)
- 在不同尺寸下测试:在最小终端尺寸(80x24)下验证表格有足够的空间
- 如有需要请迭代调整:如果表格感觉拥挤,将高度偏移减少2-4行
常见错误:不考虑额外的UI元素,对所有视图使用相同的高度计算。
❌ 错误:
go
// 带有额外信息区域的详情视图使用主视图计算方式
v.table.SetHeight(msg.Height - 12) // 表格会过大,与页脚重叠✅ 正确:
go
// 根据视图中的实际UI元素调整计算方式
v.table.SetHeight(msg.Height - 16) // 考虑额外的信息区域15. Column Consistency Between Related Views
15. 相关视图之间的列一致性
CRITICAL: When a detail view displays a list that's conceptually similar to a primary list view (e.g., workflow details showing recent runs, same as the main runs list), the columns MUST match exactly to maintain consistency and user expectations.
关键提示:当详情视图显示与主列表视图概念相似的列表时(例如工作流详情显示最近的运行,与主运行列表相同),列必须完全匹配,以保持一致性和用户预期。
Why Column Consistency Matters
列一致性的重要性
- User Experience: Users expect the same information in the same format across views
- Cognitive Load: Consistent columns reduce mental overhead when switching contexts
- Visual Familiarity: Same column structure reinforces the relationship between views
- 用户体验:用户期望在不同视图中以相同格式获取相同信息
- 认知负荷:一致的列减少切换上下文时的脑力负担
- 视觉熟悉度:相同的列结构强化视图之间的关联
Example: Runs List Columns
示例:运行列表列
Primary View ():
runs_list.gogo
columns := []table.Column{
{Title: "Task Name", Width: 30},
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25},
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12},
}Detail View ( showing recent runs for a workflow):
workflow_details.gogo
// MUST use the same columns as runs_list.go
columns := []table.Column{
{Title: "Task Name", Width: 30},
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25}, // Keep this even if redundant
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12},
}主视图():
runs_list.gogo
columns := []table.Column{
{Title: "Task Name", Width: 30},
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25},
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12},
}详情视图(显示工作流的最近运行):
workflow_details.gogo
// 必须与runs_list.go使用相同的列
columns := []table.Column{
{Title: "Task Name", Width: 30},
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25}, // 即使冗余也要保留
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12},
}Implementing Column Consistency
实现列一致性
When implementing a detail view with a related list:
- Reference the primary view: Check which columns the primary list view uses
- Copy the column structure exactly: Same titles, same order, same widths
- Keep all columns: Don't remove columns even if they seem redundant in the detail context
- Update row population: Ensure populates all columns correctly
updateTableRows()
❌ Wrong:
go
// Workflow details view using different columns than runs list
columns := []table.Column{
{Title: "Name", Width: 40}, // Different title
{Title: "Created At", Width: 16},
{Title: "Status", Width: 12}, // Different order
// Missing: Workflow, Started At, Duration
}✅ Correct:
go
// Workflow details view matching runs list exactly
columns := []table.Column{
{Title: "Task Name", Width: 30}, // Same titles
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25}, // Same order
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12}, // All columns included
}实现带有相关列表的详情视图时:
- 参考主视图:检查主列表视图使用的列
- 完全复制列结构:相同的标题、顺序和宽度
- 保留所有列:即使在详情上下文中看似冗余,也不要删除列
- 更新行填充:确保正确填充所有列
updateTableRows()
❌ 错误:
go
// 工作流详情视图使用与运行列表不同的列
columns := []table.Column{
{Title: "Name", Width: 40}, // 不同的标题
{Title: "Created At", Width: 16},
{Title: "Status", Width: 12}, // 不同的顺序
// 缺失: Workflow, Started At, Duration
}✅ 正确:
go
// 工作流详情视图与运行列表完全匹配
columns := []table.Column{
{Title: "Task Name", Width: 30}, // 相同的标题
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25}, // 相同的顺序
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12}, // 包含所有列
}16. View Navigation and Modal Selector
16. 视图导航和模态选择器
The TUI uses a navigation stack system for drilling down into details and a modal selector for switching between primary views.
TUI使用导航栈系统进行详情钻取,并使用模态选择器在主视图之间切换。
Navigation Stack Pattern
导航栈模式
The root TUI model maintains a for back navigation:
viewStackgo
type tuiModel struct {
currentView tui.View
viewStack []tui.View // Stack for back navigation
// ...
}Navigating to a Detail View:
go
case tui.NavigateToWorkflowMsg:
// Push current view onto stack
m.viewStack = append(m.viewStack, m.currentView)
// Create and initialize detail view
detailView := tui.NewWorkflowDetailsView(m.ctx, msg.WorkflowID)
detailView.SetSize(m.width, m.height)
m.currentView = detailView
return m, detailView.Init()Navigating Back:
go
case tui.NavigateBackMsg:
// Pop view from stack
if len(m.viewStack) > 0 {
m.currentView = m.viewStack[len(m.viewStack)-1]
m.viewStack = m.viewStack[:len(m.viewStack)-1]
m.currentView.SetSize(m.width, m.height)
}
return m, nilIn Detail Views (handle Esc key for back navigation):
go
case tea.KeyMsg:
switch msg.String() {
case "esc":
// Navigate back to previous view
return v, NewNavigateBackMsg()
}根TUI模型维护一个用于返回导航:
viewStackgo
type tuiModel struct {
currentView tui.View
viewStack []tui.View // 用于返回导航的栈
// ...
}导航到详情视图:
go
case tui.NavigateToWorkflowMsg:
// 将当前视图推入栈
m.viewStack = append(m.viewStack, m.currentView)
// 创建并初始化详情视图
detailView := tui.NewWorkflowDetailsView(m.ctx, msg.WorkflowID)
detailView.SetSize(m.width, m.height)
m.currentView = detailView
return m, detailView.Init()返回导航:
go
case tui.NavigateBackMsg:
// 从栈中弹出视图
if len(m.viewStack) > 0 {
m.currentView = m.viewStack[len(m.viewStack)-1]
m.viewStack = m.viewStack[:len(m.viewStack)-1]
m.currentView.SetSize(m.width, m.height)
}
return m, nil在详情视图中(处理Esc键进行返回导航):
go
case tea.KeyMsg:
switch msg.String() {
case "esc":
// 返回上一个视图
return v, NewNavigateBackMsg()
}Modal View Selector Pattern
模态视图选择器模式
The modal selector allows switching between primary views using :
Shift+TabOpening the Modal:
go
case tea.KeyMsg:
switch msg.String() {
case "shift+tab":
// Find current view type in the list
for i, opt := range availableViews {
if opt.Type == m.currentViewType {
m.selectedViewIndex = i
break
}
}
m.showViewSelector = true
return m, nil
}Modal Navigation (supports Tab, arrow keys, vim keys):
go
if m.showViewSelector {
switch msg.String() {
case "shift+tab", "tab", "down", "j":
// Cycle forward
m.selectedViewIndex = (m.selectedViewIndex + 1) % len(availableViews)
return m, nil
case "up", "k":
// Cycle backward
m.selectedViewIndex = (m.selectedViewIndex - 1 + len(availableViews)) % len(availableViews)
return m, nil
case "enter":
// Confirm selection and switch view
selectedType := availableViews[m.selectedViewIndex].Type
if selectedType != m.currentViewType {
// Only switch if in a primary view
if m.isInPrimaryView() {
m.currentViewType = selectedType
m.currentView = m.createViewForType(selectedType)
m.currentView.SetSize(m.width, m.height)
m.showViewSelector = false
return m, m.currentView.Init()
}
}
m.showViewSelector = false
return m, nil
case "esc":
// Cancel without switching
m.showViewSelector = false
return m, nil
}
return m, nil
}Rendering the Modal:
go
func (m tuiModel) renderViewSelector() string {
var b strings.Builder
// Use reusable header component
header := tui.RenderHeader("Select View", m.ctx.ProfileName, m.width)
b.WriteString(header)
b.WriteString("\n\n")
// Instructions
instructions := tui.RenderInstructions(
"↑/↓ or Tab: Navigate • Enter: Confirm • Esc: Cancel",
m.width,
)
b.WriteString(instructions)
b.WriteString("\n\n")
// View options with highlighting
for i, opt := range availableViews {
if i == m.selectedViewIndex {
// Highlighted option
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true).
Padding(0, 2)
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s - %s", opt.Name, opt.Description)))
} else {
// Non-highlighted option
normalStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
Padding(0, 2)
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s - %s", opt.Name, opt.Description)))
}
b.WriteString("\n")
}
// Footer
footer := tui.RenderFooter([]string{
"Tab: Cycle",
"Enter: Confirm",
"Esc: Cancel",
}, m.width)
b.WriteString("\n")
b.WriteString(footer)
return b.String()
}Key Principles:
- Navigation Stack: Use for hierarchical navigation (list → detail → back)
- Modal Selector: Use for switching between top-level views
- Primary View Check: Only allow view switching when not in a detail view
- Consistent Key Bindings:
- : Open view selector
Shift+Tab - : Go back (in detail views) or cancel (in modals)
Esc - : Select item or confirm action
Enter - Arrow keys/vim keys: Navigate within lists and modals
模态选择器允许使用在主视图之间切换:
Shift+Tab打开模态框:
go
case tea.KeyMsg:
switch msg.String() {
case "shift+tab":
// 在列表中查找当前视图类型
for i, opt := range availableViews {
if opt.Type == m.currentViewType {
m.selectedViewIndex = i
break
}
}
m.showViewSelector = true
return m, nil
}模态导航(支持Tab、箭头键、vim键):
go
if m.showViewSelector {
switch msg.String() {
case "shift+tab", "tab", "down", "j":
// 向前循环
m.selectedViewIndex = (m.selectedViewIndex + 1) % len(availableViews)
return m, nil
case "up", "k":
// 向后循环
m.selectedViewIndex = (m.selectedViewIndex - 1 + len(availableViews)) % len(availableViews)
return m, nil
case "enter":
// 确认选择并切换视图
selectedType := availableViews[m.selectedViewIndex].Type
if selectedType != m.currentViewType {
// 仅在主视图中切换
if m.isInPrimaryView() {
m.currentViewType = selectedType
m.currentView = m.createViewForType(selectedType)
m.currentView.SetSize(m.width, m.height)
m.showViewSelector = false
return m, m.currentView.Init()
}
}
m.showViewSelector = false
return m, nil
case "esc":
// 取消不切换
m.showViewSelector = false
return m, nil
}
return m, nil
}渲染模态框:
go
func (m tuiModel) renderViewSelector() string {
var b strings.Builder
// 使用可复用页眉组件
header := tui.RenderHeader("选择视图", m.ctx.ProfileName, m.width)
b.WriteString(header)
b.WriteString("\n\n")
// 说明
instructions := tui.RenderInstructions(
"↑/↓ 或 Tab: 导航 • Enter: 确认 • Esc: 取消",
m.width,
)
b.WriteString(instructions)
b.WriteString("\n\n")
// 带高亮的视图选项
for i, opt := range availableViews {
if i == m.selectedViewIndex {
// 高亮选项
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true).
Padding(0, 2)
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s - %s", opt.Name, opt.Description)))
} else {
// 非高亮选项
normalStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
Padding(0, 2)
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s - %s", opt.Name, opt.Description)))
}
b.WriteString("\n")
}
// 页脚
footer := tui.RenderFooter([]string{
"Tab: 循环切换",
"Enter: 确认",
"Esc: 取消",
}, m.width)
b.WriteString("\n")
b.WriteString(footer)
return b.String()
}关键原则:
- 导航栈:用于层级导航(列表 → 详情 → 返回)
- 模态选择器:用于在顶级视图之间切换
- 主视图检查:仅在非详情视图中允许切换视图
- 一致的按键绑定:
- :打开视图选择器
Shift+Tab - :返回(详情视图)或取消(模态框)
Esc - :选择项目或确认操作
Enter - 箭头键/vim键:在列表和模态框中导航
Common Patterns
常见模式
Formatting Utilities
格式化工具
Duration Formatting
时长格式化
go
func formatDuration(ms int) string {
duration := time.Duration(ms) * time.Millisecond
if duration < time.Second {
return fmt.Sprintf("%dms", ms)
}
seconds := duration.Seconds()
if seconds < 60 {
return fmt.Sprintf("%.1fs", seconds)
}
minutes := int(seconds / 60)
secs := int(seconds) % 60
return fmt.Sprintf("%dm%ds", minutes, secs)
}go
func formatDuration(ms int) string {
duration := time.Duration(ms) * time.Millisecond
if duration < time.Second {
return fmt.Sprintf("%dms", ms)
}
seconds := duration.Seconds()
if seconds < 60 {
return fmt.Sprintf("%.1fs", seconds)
}
minutes := int(seconds / 60)
secs := int(seconds) % 60
return fmt.Sprintf("%dm%ds", minutes, secs)
}ID Truncation
ID截断
go
func truncateID(id string, length int) string {
if len(id) > length {
return id[:length]
}
return id
}go
func truncateID(id string, length int) string {
if len(id) > length {
return id[:length]
}
return id
}Status Rendering
状态渲染
IMPORTANT: Do not manually style statuses. Use the status utility functions:
go
// For V1TaskStatus (from REST API)
status := styles.RenderV1TaskStatus(task.Status)
// The utility automatically handles:
// - COMPLETED -> Green "Succeeded"
// - FAILED -> Red "Failed"
// - CANCELLED -> Orange "Cancelled"
// - RUNNING -> Yellow "Running"
// - QUEUED -> Gray "Queued"
// All colors match frontend badge variants重要提示:请勿手动设置状态样式。请使用状态工具函数:
go
// 对于V1TaskStatus(来自REST API)
status := styles.RenderV1TaskStatus(task.Status)
// 工具函数自动处理:
// - COMPLETED -> 绿色"Succeeded"
// - FAILED -> 红色"Failed"
// - CANCELLED -> 橙色"Cancelled"
// - RUNNING -> 黄色"Running"
// - QUEUED -> 灰色"Queued"
// 所有颜色与前端徽章变体匹配Auto-refresh Pattern
自动刷新模式
go
// Define tick message in your view file
type tickMsg time.Time
// Create tick command
func tick() tea.Cmd {
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// Handle in Update
case tickMsg:
// Refresh data
return v, tea.Batch(v.fetchData(), tick())go
// 在您的视图文件中定义tick消息
type tickMsg time.Time
// 创建tick命令
func tick() tea.Cmd {
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// 在Update中处理
case tickMsg:
// 刷新数据
return v, tea.Batch(v.fetchData(), tick())Debug Logging Pattern
调试日志模式
Important: For views that make API calls or have complex state management, implement a debug logging system using a ring buffer to prevent memory leaks.
重要提示:对于发起API调用或具有复杂状态管理的视图,请使用环形缓冲区实现调试日志系统,以防止内存泄漏。
Step 1: Create Debug Logger (if not exists)
步骤1:创建调试日志器(如果不存在)
Create :
cmd/hatchet-cli/cli/tui/debug.gogo
package views
import (
"fmt"
"sync"
"time"
)
// DebugLog represents a single debug log entry
type DebugLog struct {
Timestamp time.Time
Message string
}
// DebugLogger is a fixed-size ring buffer for debug logs
type DebugLogger struct {
mu sync.RWMutex
logs []DebugLog
capacity int
index int
size int
}
// NewDebugLogger creates a new debug logger with the specified capacity
func NewDebugLogger(capacity int) *DebugLogger {
return &DebugLogger{
logs: make([]DebugLog, capacity),
capacity: capacity,
index: 0,
size: 0,
}
}
// Log adds a new log entry to the ring buffer
func (d *DebugLogger) Log(format string, args ...interface{}) {
d.mu.Lock()
defer d.mu.Unlock()
d.logs[d.index] = DebugLog{
Timestamp: time.Now(),
Message: fmt.Sprintf(format, args...),
}
d.index = (d.index + 1) % d.capacity
if d.size < d.capacity {
d.size++
}
}
// GetLogs returns all logs in chronological order
func (d *DebugLogger) GetLogs() []DebugLog {
d.mu.RLock()
defer d.mu.RUnlock()
if d.size == 0 {
return []DebugLog{}
}
result := make([]DebugLog, d.size)
if d.size < d.capacity {
// Buffer not full yet, logs are from 0 to index-1
copy(result, d.logs[:d.size])
} else {
// Buffer is full, logs wrap around
// Copy from index to end (older logs)
n := copy(result, d.logs[d.index:])
// Copy from start to index (newer logs)
copy(result[n:], d.logs[:d.index])
}
return result
}
// Clear removes all logs
func (d *DebugLogger) Clear() {
d.mu.Lock()
defer d.mu.Unlock()
d.index = 0
d.size = 0
}
// Size returns the current number of logs
func (d *DebugLogger) Size() int {
d.mu.RLock()
defer d.mu.RUnlock()
return d.size
}
// Capacity returns the maximum capacity
func (d *DebugLogger) Capacity() int {
return d.capacity
}创建:
cmd/hatchet-cli/cli/tui/debug.gogo
package views
import (
"fmt"
"sync"
"time"
)
// DebugLog表示单个调试日志条目
type DebugLog struct {
Timestamp time.Time
Message string
}
// DebugLogger是用于调试日志的固定大小环形缓冲区
type DebugLogger struct {
mu sync.RWMutex
logs []DebugLog
capacity int
index int
size int
}
// NewDebugLogger创建具有指定容量的新调试日志器
func NewDebugLogger(capacity int) *DebugLogger {
return &DebugLogger{
logs: make([]DebugLog, capacity),
capacity: capacity,
index: 0,
size: 0,
}
}
// Log向环形缓冲区添加新的日志条目
func (d *DebugLogger) Log(format string, args ...interface{}) {
d.mu.Lock()
defer d.mu.Unlock()
d.logs[d.index] = DebugLog{
Timestamp: time.Now(),
Message: fmt.Sprintf(format, args...),
}
d.index = (d.index + 1) % d.capacity
if d.size < d.capacity {
d.size++
}
}
// GetLogs按时间顺序返回所有日志
func (d *DebugLogger) GetLogs() []DebugLog {
d.mu.RLock()
defer d.mu.RUnlock()
if d.size == 0 {
return []DebugLog{}
}
result := make([]DebugLog, d.size)
if d.size < d.capacity {
// 缓冲区未满,日志从0到index-1
copy(result, d.logs[:d.size])
} else {
// 缓冲区已满,日志环绕
// 从index到末尾复制(较旧的日志)
n := copy(result, d.logs[d.index:])
// 从开头到index复制(较新的日志)
copy(result[n:], d.logs[:d.index])
}
return result
}
// Clear清除所有日志
func (d *DebugLogger) Clear() {
d.mu.Lock()
defer d.mu.Unlock()
d.index = 0
d.size = 0
}
// Size返回当前日志数量
func (d *DebugLogger) Size() int {
d.mu.RLock()
defer d.mu.RUnlock()
return d.size
}
// Capacity返回最大容量
func (d *DebugLogger) Capacity() int {
return d.capacity
}Step 2: Integrate Debug Logger in Your View
步骤2:在您的视图中集成调试日志器
go
type YourView struct {
BaseModel
// ... other fields
debugLogger *DebugLogger
showDebug bool // Whether to show debug overlay
}
func NewYourView(ctx ViewContext) *YourView {
v := &YourView{
BaseModel: BaseModel{
Ctx: ctx,
},
debugLogger: NewDebugLogger(5000), // 5000 log entries max
showDebug: false,
}
v.debugLogger.Log("YourView initialized")
return v
}go
type YourView struct {
BaseModel
// ... 其他字段
debugLogger *DebugLogger
showDebug bool // 是否显示调试覆盖层
}
func NewYourView(ctx ViewContext) *YourView {
v := &YourView{
BaseModel: BaseModel{
Ctx: ctx,
},
debugLogger: NewDebugLogger(5000), // 最多5000条日志条目
showDebug: false,
}
v.debugLogger.Log("YourView已初始化")
return v
}Step 3: Add Debug Logging Throughout View
步骤3:在视图中添加调试日志
go
// Log important events
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
v.debugLogger.Log("Fetching data...")
// Make API call
response, err := v.Ctx.Client.API().SomeEndpoint(...)
if err != nil {
v.debugLogger.Log("Error fetching data: %v", err)
return dataMsg{err: err}
}
v.debugLogger.Log("Successfully fetched %d items", len(response.Items))
return dataMsg{data: response.Items}
}
}go
// 记录重要事件
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
v.debugLogger.Log("正在获取数据...")
// 发起API调用
response, err := v.Ctx.Client.API().SomeEndpoint(...)
if err != nil {
v.debugLogger.Log("获取数据错误: %v", err)
return dataMsg{err: err}
}
v.debugLogger.Log("成功获取%d条数据", len(response.Items))
return dataMsg{data: response.Items}
}
}Step 4: Add Toggle Key Handler
步骤4:添加切换按键处理
go
func (v *YourView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "d":
// Toggle debug view
v.showDebug = !v.showDebug
v.debugLogger.Log("Debug view toggled: %v", v.showDebug)
return v, nil
case "c":
// Clear debug logs (only when in debug view)
if v.showDebug {
v.debugLogger.Clear()
v.debugLogger.Log("Debug logs cleared")
}
return v, nil
}
}
// ... rest of update logic
}go
func (v *YourView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "d":
// 切换调试视图
v.showDebug = !v.showDebug
v.debugLogger.Log("调试视图已切换: %v", v.showDebug)
return v, nil
case "c":
// 清除调试日志(仅在调试视图中)
if v.showDebug {
v.debugLogger.Clear()
v.debugLogger.Log("调试日志已清除")
}
return v, nil
}
}
// ... 其余更新逻辑
}Step 5: Implement Debug View Rendering
步骤5:实现调试视图渲染
go
func (v *YourView) View() string {
if v.Width == 0 {
return "Initializing..."
}
// If debug view is enabled, show debug overlay
if v.showDebug {
return v.renderDebugView()
}
// ... normal view rendering
}
func (v *YourView) renderDebugView() string {
logs := v.debugLogger.GetLogs()
// Header
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
BorderForeground(styles.AccentColor).
Width(v.Width-4).
Padding(0, 1)
header := headerStyle.Render(fmt.Sprintf(
"Debug Logs - %d/%d entries",
v.debugLogger.Size(),
v.debugLogger.Capacity(),
))
// Log entries
logStyle := lipgloss.NewStyle().
Padding(0, 1).
Width(v.Width - 4)
var b strings.Builder
b.WriteString(header)
b.WriteString("\n\n")
// Calculate how many logs we can show
maxLines := v.Height - 8 // Reserve space for header, footer, controls
if maxLines < 1 {
maxLines = 1
}
// Show most recent logs first
startIdx := 0
if len(logs) > maxLines {
startIdx = len(logs) - maxLines
}
for i := startIdx; i < len(logs); i++ {
log := logs[i]
timestamp := log.Timestamp.Format("15:04:05.000")
logLine := fmt.Sprintf("[%s] %s", timestamp, log.Message)
b.WriteString(logStyle.Render(logLine))
b.WriteString("\n")
}
// Footer with controls
footerStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
BorderStyle(lipgloss.NormalBorder()).
BorderTop(true).
BorderForeground(styles.AccentColor).
Width(v.Width-4).
Padding(0, 1)
controls := footerStyle.Render("d: Close Debug | c: Clear Logs | q: Quit")
b.WriteString("\n")
b.WriteString(controls)
return b.String()
}go
func (v *YourView) View() string {
if v.Width == 0 {
return "初始化中..."
}
// 如果启用调试视图,显示调试覆盖层
if v.showDebug {
return v.renderDebugView()
}
// ... 正常视图渲染
}
func (v *YourView) renderDebugView() string {
logs := v.debugLogger.GetLogs()
// 页眉
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
BorderForeground(styles.AccentColor).
Width(v.Width-4).
Padding(0, 1)
header := headerStyle.Render(fmt.Sprintf(
"调试日志 - %d/%d条条目",
v.debugLogger.Size(),
v.debugLogger.Capacity(),
))
// 日志条目
logStyle := lipgloss.NewStyle().
Padding(0, 1).
Width(v.Width - 4)
var b strings.Builder
b.WriteString(header)
b.WriteString("\n\n")
// 计算可显示的日志数量
maxLines := v.Height - 8 // 为页眉、页脚、控件预留空间
if maxLines < 1 {
maxLines = 1
}
// 显示最新的日志
startIdx := 0
if len(logs) > maxLines {
startIdx = len(logs) - maxLines
}
for i := startIdx; i < len(logs); i++ {
log := logs[i]
timestamp := log.Timestamp.Format("15:04:05.000")
logLine := fmt.Sprintf("[%s] %s", timestamp, log.Message)
b.WriteString(logStyle.Render(logLine))
b.WriteString("\n")
}
// 带控件的页脚
footerStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
BorderStyle(lipgloss.NormalBorder()).
BorderTop(true).
BorderForeground(styles.AccentColor).
Width(v.Width-4).
Padding(0, 1)
controls := footerStyle.Render("d: 关闭调试 | c: 清除日志 | q: 退出")
b.WriteString("\n")
b.WriteString(controls)
return b.String()
}Step 6: Update Footer Controls
步骤6:更新页脚控件
Add debug controls to your normal view footer:
go
controls := footerStyle.Render("↑/↓: Navigate | r: Refresh | d: Debug | q: Quit")Benefits:
- Fixed-size ring buffer prevents memory leaks
- Thread-safe with mutex protection
- Toggle on/off without restarting TUI
- Helps diagnose API issues and state changes
- No performance impact when not viewing logs
在正常视图页脚中添加调试控件:
go
controls := footerStyle.Render("↑/↓: 导航 | r: 刷新 | d: 调试 | q: 退出")优势:
- 固定大小的环形缓冲区防止内存泄漏
- 互斥锁保护确保线程安全
- 无需重启TUI即可切换开关
- 有助于诊断API问题和状态变化
- 不查看日志时无性能影响
Testing Approach
测试方法
Dummy Data Generation
模拟数据生成
During development, create dummy data generators in your view file:
go
func generateDummyData() []YourDataType {
now := time.Now()
return []YourDataType{
{
Field1: "value1",
Field2: "value2",
CreatedAt: now.Add(-5 * time.Minute),
},
// ... more dummy items
}
}开发期间,请在您的视图文件中创建模拟数据生成器:
go
func generateDummyData() []YourDataType {
now := time.Now()
return []YourDataType{
{
Field1: "value1",
Field2: "value2",
CreatedAt: now.Add(-5 * time.Minute),
},
// ... 更多模拟数据
}
}Example Reference
示例参考
File Structure Example
文件结构示例
cmd/hatchet-cli/cli/
├── tui.go # Root TUI command
└── views/
├── view.go # View interface and base types
├── tasks.go # Tasks view implementation
└── workflows.go # Workflows view implementation (future)cmd/hatchet-cli/cli/
├── tui.go # 根TUI命令
└── views/
├── view.go # 视图接口和基础类型
├── tasks.go # Tasks视图实现
└── workflows.go # Workflows视图实现(未来)Complete View Example
完整视图示例
See for a complete implementation.
cmd/hatchet-cli/cli/tui/tasks.go请参阅获取完整实现。
cmd/hatchet-cli/cli/tui/tasks.goCompilation and Testing
编译和测试
CRITICAL: Always ensure the CLI binary compiles before considering work complete.
关键提示:在完成工作之前,请始终确保CLI二进制文件可编译。
Compilation Check
编译检查
After implementing or modifying any view:
bash
undefined实现或修改任何视图后:
bash
undefinedBuild the CLI binary
构建CLI二进制文件
go build -o /tmp/hatchet-test ./cmd/hatchet-cli
go build -o /tmp/hatchet-test ./cmd/hatchet-cli
Check for errors
检查错误
echo $? # Should be 0 for success
undefinedecho $? # 成功时应为0
undefinedCommon Compilation Issues
常见编译问题
-
UUID Type Mismatchesgo
// ❌ Wrong - string to UUID client.API().SomeMethod(ctx, client.TenantId(), ...) // ✅ Correct - parse and convert tenantUUID, err := uuid.Parse(client.TenantId()) if err != nil { return msg{err: fmt.Errorf("invalid tenant ID: %w", err)} } client.API().SomeMethod(ctx, openapi_types.UUID(tenantUUID), ...) -
Required Importsgo
import ( "github.com/google/uuid" openapi_types "github.com/oapi-codegen/runtime/types" ) -
Type Conversions for API Params
- not
*int64for offset/limit*int - not
time.Timefor Since parameter (check the generated types)*time.Time - for tenant and workflow IDs
openapi_types.UUID - Check for exact parameter types
pkg/client/rest/gen.go
-
Pointer Helper Functionsgo
func int64Ptr(i int64) *int64 { return &i }
-
UUID类型不匹配go
// ❌ 错误 - 字符串转UUID client.API().SomeMethod(ctx, client.TenantId(), ...) // ✅ 正确 - 解析并转换 tenantUUID, err := uuid.Parse(client.TenantId()) if err != nil { return msg{err: fmt.Errorf("无效的租户ID: %w", err)} } client.API().SomeMethod(ctx, openapi_types.UUID(tenantUUID), ...) -
必要的导入go
import ( "github.com/google/uuid" openapi_types "github.com/oapi-codegen/runtime/types" ) -
API参数的类型转换
- offset/limit使用而非
*int64*int - Since参数使用而非
time.Time(请检查生成的类型)*time.Time - 租户和工作流ID使用
openapi_types.UUID - 请检查获取确切的参数类型
pkg/client/rest/gen.go
- offset/limit使用
-
指针辅助函数go
func int64Ptr(i int64) *int64 { return &i }
Testing Workflow
测试流程
-
Compilation Testbash
go build -o /tmp/hatchet-test ./cmd/hatchet-cli -
Linting TestAfter the build succeeds, run the linting checks:bash
task pre-commit-runContinue running this command until it succeeds. Fix any linting issues that are reported before proceeding. -
Basic Functionality Testbash
# Test with profile selection /tmp/hatchet-test tui # Test with specific profile /tmp/hatchet-test tui --profile your-profile -
Error Handling Test
- Try without profiles configured
- Try with invalid profile
- Test keyboard controls (q, r, arrows)
-
Visual/Layout TestingWhen implementing a new view:
- Test at various terminal sizes (minimum 80x24)
- Ensure header and footer are always visible
- Verify instructions are clear and helpful
- Check that navigation controls are consistent with other views
- Test with both light and dark terminal backgrounds
- Verify all reusable components render correctly
-
编译测试bash
go build -o /tmp/hatchet-test ./cmd/hatchet-cli -
** lint测试**构建成功后,运行lint检查:bash
task pre-commit-run持续运行此命令直到成功。在继续之前,请修复所有报告的lint问题。 -
基本功能测试bash
# 测试配置文件选择 /tmp/hatchet-test tui # 使用特定配置文件测试 /tmp/hatchet-test tui --profile your-profile -
错误处理测试
- 尝试在未配置配置文件的情况下运行
- 尝试使用无效配置文件
- 测试键盘控件(q、r、箭头键)
-
视觉/布局测试实现新视图时:
- 在各种终端尺寸下测试(最小80x24)
- 确保页眉和页脚始终可见
- 验证说明清晰有用
- 检查导航控件与其他视图一致
- 在亮色和暗色终端背景下测试
- 验证所有可复用组件渲染正确
Checklist for New TUI Views
新TUI视图检查清单
Before You Start
开始之前
- Find the corresponding frontend view in
frontend/app/src/pages/main/v1/ - Identify column structure and API calls from frontend
- Note the exact API endpoint and parameters used
- 在中找到对应的前端视图
frontend/app/src/pages/main/v1/ - 从前端识别列结构和API调用
- 记录使用的确切API端点和参数
Creating the View
创建视图
- Create new file in
cmd/hatchet-cli/cli/tui/{viewname}.go - Add required imports (including and
uuidif needed)openapi_types - Define view struct that embeds
BaseModel - Create constructor
NewYourView(ctx ViewContext) - Implement method
Init() - Implement method
Update(msg tea.Msg) - Implement method following standard structure
View() - Implement method
SetSize(width, height int) - USE REUSABLE COMPONENTS: ,
RenderHeader(),RenderInstructions()RenderFooter() - DO NOT copy-paste header/footer styling code
- Apply Hatchet theme colors and styles for custom components only
- Implement view-specific keyboard controls
- Document all keyboard controls in footer using
RenderFooter() - Use appropriate REST API types
- Add error handling using
BaseModel.HandleError() - Add responsive layout (handle WindowSizeMsg)
- 在中创建新文件
cmd/hatchet-cli/cli/tui/{viewname}.go - 添加必要的导入(如需要,包括和
uuid)openapi_types - 定义嵌入的视图结构体
BaseModel - 创建构造函数
NewYourView(ctx ViewContext) - 实现方法
Init() - 实现方法
Update(msg tea.Msg) - 遵循标准结构实现方法
View() - 实现方法
SetSize(width, height int) - 使用可复用组件:、
RenderHeader()、RenderInstructions()RenderFooter() - 请勿复制粘贴页眉/页脚样式代码
- 仅对自定义组件应用Hatchet主题颜色和样式
- 实现视图特定的键盘控件
- 使用在页脚中记录所有键盘控件
RenderFooter() - 使用适当的REST API类型
- 使用添加错误处理
BaseModel.HandleError() - 添加响应式布局(处理WindowSizeMsg)
API Integration
API集成
- Parse tenant ID to UUID if needed
- Use correct parameter types (,
*int64, etc.)time.Time - Handle API response errors
- Format data for table display
- Add loading states and error display
- 如有需要,将租户ID解析为UUID
- 使用正确的参数类型(、
*int64等)time.Time - 处理API响应错误
- 格式化数据以用于表格显示
- 添加加载状态和错误显示
Integration and Testing
集成和测试
- Import view in
tui.go - Update to instantiate your view
newTUIModel() - Compile the CLI binary ()
go build ./cmd/hatchet-cli - Fix any compilation errors
- Test basic functionality with real profile
- Test error cases (no profile, invalid profile)
- Test keyboard controls (q, r, arrows)
- Update TUI command documentation
- 在中导入视图
tui.go - 更新以实例化您的视图
newTUIModel() - 编译CLI二进制文件()
go build ./cmd/hatchet-cli - 修复任何编译错误
- 使用真实配置文件测试基本功能
- 测试错误情况(无配置文件、无效配置文件)
- 测试键盘控件(q、r、箭头键)
- 更新TUI命令文档
Best Practices
最佳实践
- CRITICAL: Use reusable components (,
RenderHeader,RenderInstructions)RenderFooter - NEVER copy-paste header/footer styling code - this violates DRY principle
- Keep view logic isolated in the view file
- Use to access client and profile info
ViewContext - Handle all messages gracefully (return for unhandled)
v, nil - Always check before rendering
v.Width == 0 - Use consistent styling with other views (use Hatchet theme colors)
- Document ALL keyboard controls in footer using
RenderFooter() - Follow the standard view structure (header, instructions, content, footer)
- Always verify compilation before submitting
- 关键:使用可复用组件(、
RenderHeader、RenderInstructions)RenderFooter - 请勿复制粘贴页眉/页脚样式代码 - 这违反DRY原则
- 将视图逻辑隔离在视图文件中
- 使用访问客户端和配置文件信息
ViewContext - 优雅处理所有消息(未处理的消息返回)
v, nil - 渲染前始终检查
v.Width == 0 - 与其他视图使用一致的样式(使用Hatchet主题颜色)
- 使用记录所有键盘控件
RenderFooter() - 遵循标准视图结构(页眉、说明、内容、页脚)
- 提交前始终验证编译
Post-Implementation
实现后
- Update this skill document with any new patterns or learnings discovered
- Document any issues encountered and their solutions
- Add any new utility functions or patterns to the appropriate sections
- Verify all code examples and file paths are accurate
- 更新本技能文档,添加任何发现的新模式或经验
- 记录遇到的任何问题及其解决方案
- 将任何新的工具函数或模式添加到相应章节
- 验证所有代码示例和文件路径准确
Lessons Learned & Updates
经验总结与更新
This section documents recent learnings and updates to maintain accuracy.
本节记录近期的经验总结和更新,以保持文档准确性。
Recent Updates
近期更新
- 2026-01-10: Major updates based on workers view implementation and bug fixes:
- Added workers list view and worker details view as reference implementations
- Updated common REST API types list to include Worker, WorkerRuntimeInfo, Workflow
- Documented detail view header patterns (showing specific resource names in titles)
- Added section on filtering with multi-select forms and custom key maps
- Documented per-cell table styling using TableWithStyleFunc wrapper
- Added examples of status badge rendering in detail views
- Documented navigation messages (NavigateToWorkerMsg pattern)
- Added column alignment best practices (matching header format strings to row rendering)
- Workflow TUI implementation updates:
- Updated header component documentation: ALL headers (primary, detail, modal) now use highlight color for consistency
- Added for primary views (shows just view name, non-repetitive)
RenderHeaderWithViewIndicator() - Added section 14: Table Height Calculations and Layout Optimization (height - 12 vs height - 16)
- Added section 15: Column Consistency Between Related Views (critical for UX)
- Added section 16: View Navigation and Modal Selector patterns
- Documented modal selector with Shift+Tab and arrow key support
- Documented navigation stack pattern for detail view drilling
- 2026-01-09: Added self-updating instructions and verification checklist
- Document initialized with comprehensive TUI view building guidelines
- 2026-01-10:基于workers视图实现和bug修复的重大更新:
- 添加workers列表视图和worker详情视图作为参考实现
- 更新常见REST API类型列表,包含Worker、WorkerRuntimeInfo、Workflow
- 记录详情视图页眉模式(在标题中显示特定资源名称)
- 添加多选择表单和自定义按键映射的过滤章节
- 记录使用TableWithStyleFunc包装器的单元格级表格样式
- 添加详情视图中状态徽章渲染的示例
- 记录导航消息(NavigateToWorkerMsg模式)
- 添加列对齐最佳实践(将页眉格式字符串与行渲染匹配)
- 工作流TUI实现更新:
- 更新页眉组件文档:所有页眉(主视图、详情视图、模态框)现在一致使用高亮色
- 为主视图添加(仅显示视图名称,无重复)
RenderHeaderWithViewIndicator() - 添加第14节:表格高度计算和布局优化(height - 12 vs height - 16)
- 添加第15节:相关视图之间的列一致性(对UX至关重要)
- 添加第16节:视图导航和模态选择器模式
- 记录支持Shift+Tab和箭头键的模态选择器
- 记录详情视图钻取的导航栈模式
- 2026-01-09:添加自更新说明和验证清单
- 初始化文档,包含全面的TUI视图构建指南
Known Issues & Solutions
已知问题与解决方案
Issue: Table Column Alignment Mismatches
问题:表格列对齐不匹配
Problem: Header columns don't align with table rows due to format string width mismatch.
Example: In run details tasks tab, header used for selector column but rows only rendered 2 characters ("▸ " or " "), causing status column and all subsequent columns to be misaligned.
%-3sSolution: Ensure header format string widths exactly match row rendering:
go
// Header format - 2 chars for selector to match "▸ " or " "
headerStyle.Render(fmt.Sprintf("%-2s %-30s %-12s", "", "NAME", "STATUS"))
// Row rendering - also 2 chars
if selected {
b.WriteString("▸ ") // 2 characters
} else {
b.WriteString(" ") // 2 characters
}Prevention: Always count the exact characters rendered in rows and match header format widths precisely.
问题:由于格式字符串宽度不匹配,页眉列与表格行不对齐。
示例:在运行详情的tasks标签页中,页眉对选择器列使用,但行仅渲染2个字符("▸ "或" "),导致状态列及后续所有列对齐错误。
%-3s解决方案:确保页眉格式字符串宽度与行渲染完全匹配:
go
// 页眉格式 - 选择器使用2个字符以匹配"▸ "或" "
headerStyle.Render(fmt.Sprintf("%-2s %-30s %-12s", "", "NAME", "STATUS"))
// 行渲染 - 同样使用2个字符
if selected {
b.WriteString("▸ ") // 2个字符
} else {
b.WriteString(" ") // 2个字符
}预防:始终准确计算行中渲染的字符数,并精确匹配页眉格式宽度。
Issue: Detail View Headers Too Generic
问题:详情视图页眉过于通用
Problem: Detail views showed generic titles like "Task Details" or "Workflow Run Details" without identifying the specific resource being viewed.
Solution: Include the resource name in the header title:
go
// For task details
title := "Task Details"
if v.task != nil {
title = fmt.Sprintf("Task Details: %s", v.task.DisplayName)
}
// For workflow details
title := "Workflow Details"
if v.workflow != nil {
title = fmt.Sprintf("Workflow Details: %s", v.workflow.Name)
}
// For run details
title := "Run Details"
if v.details != nil && v.details.Run.DisplayName != "" {
title = fmt.Sprintf("Run Details: %s", v.details.Run.DisplayName)
}Pattern: Use format for all detail views.
"{View Type} Details: {Resource Name}"问题:详情视图显示通用标题,如"Task Details"或"Workflow Run Details",未显示正在查看的特定资源。
解决方案:在页眉标题中包含资源名称:
go
// 任务详情
title := "任务详情"
if v.task != nil {
title = fmt.Sprintf("任务详情: %s", v.task.DisplayName)
}
// 工作流详情
title := "工作流详情"
if v.workflow != nil {
title = fmt.Sprintf("工作流详情: %s", v.workflow.Name)
}
// 运行详情
title := "运行详情"
if v.details != nil && v.details.Run.DisplayName != "" {
title = fmt.Sprintf("运行详情: %s", v.details.Run.DisplayName)
}模式:所有详情视图使用格式。
"{视图类型}详情: {资源名称}"Issue: Filter Form Navigation Conflicts
问题:过滤表单导航冲突
Problem: Global Shift+Tab handler for view switching conflicts with form navigation, preventing Tab/Shift+Tab from working in filter modals.
Solution: Process filter form messages BEFORE checking global key handlers:
go
// In Update(), handle form FIRST
if v.showingFilter && v.filterForm != nil {
form, cmd := v.filterForm.Update(msg)
if f, ok := form.(*huh.Form); ok {
v.filterForm = f
if v.filterForm.State == huh.StateCompleted {
// Apply filters
v.selectedStatuses = v.tempStatusFilters
v.showingFilter = false
v.updateTableRows()
return v, nil
}
// Check for ESC to cancel
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" {
v.showingFilter = false
return v, nil
}
}
}
return v, cmd
}
// THEN handle global keys
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "shift+tab":
// Global view switcher
}
}Pattern: Always delegate to active modal/form components before processing global keyboard shortcuts.
问题:用于视图切换的全局Shift+Tab处理与表单导航冲突,导致Tab/Shift+Tab在过滤模态框中无法工作。
解决方案:处理全局按键之前先处理过滤表单消息:
go
// 在Update()中,先处理表单
if v.showingFilter && v.filterForm != nil {
form, cmd := v.filterForm.Update(msg)
if f, ok := form.(*huh.Form); ok {
v.filterForm = f
if v.filterForm.State == huh.StateCompleted {
// 应用过滤器
v.selectedStatuses = v.tempStatusFilters
v.showingFilter = false
v.updateTableRows()
return v, nil
}
// 检查ESC取消
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" {
v.showingFilter = false
return v, nil
}
}
}
return v, cmd
}
// 然后处理全局按键
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "shift+tab":
// 全局视图切换器
}
}模式:处理全局键盘快捷键之前,始终将消息委托给活动的模态框/表单组件。
Issue: Filtered Workers Not Reflected in Navigation
问题:过滤后的Worker未在导航中反映
Problem: When navigating to worker details via Enter key, code used unfiltered list instead of filtered list, causing cursor index mismatch with displayed rows.
v.workersSolution: Use the filtered/displayed list for navigation:
go
case "enter":
// Use filteredWorkers, not workers
if len(v.filteredWorkers) > 0 {
selectedIdx := v.table.Cursor()
if selectedIdx >= 0 && selectedIdx < len(v.filteredWorkers) {
worker := v.filteredWorkers[selectedIdx]
workerID := worker.Metadata.Id
return v, NewNavigateToWorkerMsg(workerID)
}
}Pattern: Always use the same data source for rendering and navigation. If you cache filtered data for StyleFunc, use that cached data for navigation too.
问题:通过Enter键导航到worker详情时,代码使用未过滤的列表而非过滤后的列表,导致光标索引与显示的行不匹配。
v.workers解决方案:使用过滤/显示的列表进行导航:
go
case "enter":
// 使用filteredWorkers而非workers
if len(v.filteredWorkers) > 0 {
selectedIdx := v.table.Cursor()
if selectedIdx >= 0 && selectedIdx < len(v.filteredWorkers) {
worker := v.filteredWorkers[selectedIdx]
workerID := worker.Metadata.Id
return v, NewNavigateToWorkerMsg(workerID)
}
}模式:始终使用相同的数据源进行渲染和导航。如果为StyleFunc缓存了过滤数据,导航也使用该缓存数据。
Future Improvements
未来改进
(This section will track potential improvements to the TUI system or this skill document)
(本节将跟踪TUI系统或本技能文档的潜在改进)