关于 build-tui-view
build-tui-view 是用于借助 bubbletea 和 lipgloss 创建、维护 Hatchet CLI 终端界面的技能,涵盖视图架构、可复用组件,以及对照前端实现并校正文档的方法。
主要功能
- 01bubbletea 与 lipgloss 视图指南
- 02Hatchet CLI 模块化视图架构
- 03可复用的标题、说明和页脚
- 04前端与 API 参考流程
- 05自我修正和文档核查
适用场景
- 01实现 Hatchet CLI 列表视图
- 02用共享组件构建详情视图
- 03使 TUI 列与前端数据保持一致
- 04发现代码变化后更新指南
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ hatchet-dev/ hatchet --skill build-tui-view安装后在 Claude Code 中输入/build-tui-view确认已加载。
详细介绍
📝 自更新文档:发现不准确之处或学习到新的模式时,此技能会自动更新。请务必根据实际代码库核实信息,并在必要时更新此文件。
概述
此技能提供了有关如何在 Hatchet CLI 中使用 bubbletea 和 lipgloss 创建和维护终端用户界面(TUI)视图的说明。TUI 系统采用模块化视图架构,各个视图分别存放在 views/ 目录中的独立文件内。
重要:请务必先在前端应用中找到对应的视图,以了解其结构、列和 API 调用。
自更新技能说明
关键——请先阅读:此技能文档旨在持续改进并保持准确。
何时更新此技能
遇到以下情况时,必须更新此技能文件:
-
发现不准确之处
- 发现文件路径或目录结构有误
- 代码示例无法编译,或与实际实现不符
- API 签名已更改
- 引用的文件在指定位置不存在
-
学习新模式
- 实现新视图时发现了更好的方法
- 用户向你介绍了新的约定或模式
- 发现了值得记录的可复用模式
- 发现了应该提醒他人注意的常见问题
-
发现信息缺失
- 需要的信息未在此处记录
- 代码库中添加了新组件或工具
- 采用了新的 bubbletea/lipgloss 模式
-
用户纠正
- 用户纠正了本文档中的任何信息
- 用户提供了更新后的方法或约定
- 用户指出了过时的信息
如何更新此技能
更新此技能时:
- 添加前先核实:将路径、代码和 API 签名添加到本文档前,务必先对照实际代码库进行核实
- 使用 Read/Glob/Grep:检查实际文件,确保信息准确
- 测试代码示例:确保代码示例可以编译,并遵循当前模式
- 明确具体:提供准确的文件路径、函数签名和可运行的代码示例
- 立即更新:发现不准确之处后立即更新,不要等到会话结束
- 保留结构:保持现有文档结构和格式
- 补充背景:添加新章节时,说明推荐此模式的原因
核实清单
使用此技能中的信息前,请核实:
- 文件路径存在且正确
- 代码示例与当前实现一致
- API 签名与生成的 REST 客户端一致
- 对可复用组件的引用正确
- 目录结构准确
自我纠正流程
如果工作中发现不准确之处:
- 立即记下问题
- 阅读实际文件,核实正确信息
- 更正此技能文档
- 使用更正后的信息继续完成用户的任务
请记住:此技能应是一份持续完善的文档,每次使用后都能变得更加准确、全面。
项目背景
- 框架:bubbletea(TUI 框架)
- 样式:lipgloss(样式定义)
- TUI 命令位置:
cmd/hatchet-cli/cli/tui.go - 视图位置:
cmd/hatchet-cli/cli/tui/目录 - 主题:
cmd/hatchet-cli/cli/internal/styles/styles.go中预定义的 Hatchet 主题 - 前端参考:
frontend/app/src/pages/main/v1/目录
查找前端先例
关键第一步:在实现任何 TUI 视图之前,先找到对应的前端视图,以了解:
- 列结构和名称
- API 端点和查询参数
- 使用的数据类型和字段
- 筛选和排序逻辑
查找前端参考的流程:
-
定位前端视图
# 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 -
研究列定义
- 查找
{feature}-columns.tsx等文件 - 记下列键、标题和访问器
- 示例:
frontend/app/src/pages/main/v1/workflow-runs-v1/components/v1/task-runs-columns.tsx
export 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中检查查询定义 - 记下端点名称和参数
- 示例:
v1WorkflowRuns: { list: (tenant: string, query: V2ListWorkflowRunsQuery) => ({ queryKey: ['v1:workflow-run:list', tenant, query], queryFn: async () => (await api.v1WorkflowRunList(tenant, query)).data, }), } - 在
-
映射到 Go REST 客户端
- 前端的
api.v1WorkflowRunList()对应 Go 的client.API().V1WorkflowRunListWithResponse() - 前端查询参数映射到 Go 结构体参数
- 映射示例:
// 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, }, ) - 前端的
示例:根据前端参考实现任务视图
-
前端结构:
frontend/app/src/pages/main/v1/workflow-runs-v1/- 列:
task-runs-columns.tsx - Hook:
use-runs.tsx - 表格:
runs-table.tsx
- 列:
-
提取列名称:
taskName, status, workflow, createdAt, startedAt, duration; -
识别 API 调用:
queries.v1WorkflowRuns.list(tenantId, { offset, limit, statuses, workflow_ids, since, until, only_tasks: true, }); -
在 TUI 中实现:
// 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, }, )
可复用组件(关键——请先阅读)
重要:所有 TUI 视图都必须使用 view.go 中定义的标准可复用组件,以确保整个应用风格一致。不要复制粘贴页眉/页脚样式代码。
页眉组件
关键:整个 TUI 中的所有页眉都使用洋红色高亮(styles.HighlightColor)显示标题,以便在所有视图(主视图、详情视图、模态框等)中保持一致的视觉强调效果。
详情视图和模态框
详情视图、模态框和次级屏幕始终使用 RenderHeader():
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)
主视图
主视图/列表视图使用 RenderHeaderWithViewIndicator():
// 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)
此函数仅以高亮色显示视图名称(例如,“Runs”或“Workflows”),保持简洁,避免重复。
这两个页眉函数的特性:
- 使用洋红色高亮(
styles.HighlightColor)渲染标题——所有视图保持一致 - 右侧包含 Logo(文本形式:“HATCHET TUI”)
- 显示配置名称
- 底部带边框
- 可根据终端宽度自适应
❌ 绝不要这样做:
// 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)
✅ 始终这样做:
// 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)
说明组件
使用 RenderInstructions() 显示上下文相关的帮助文本:
instructions := RenderInstructions(
"Your instructions here • Use bullets to separate items",
v.Width,
)
特性:
- 使用低调的颜色样式,减少视觉干扰
- 自动适应宽度限制
- 内边距一致
- 使用项目符号分隔符(•)
页脚组件
导航/控制提示始终使用 RenderFooter():
footer := RenderFooter([]string{
"↑/↓: Navigate",
"Enter: Select",
"Esc: Cancel",
"q: Quit",
}, v.Width)
特性:
- 带顶部边框,样式一致
- 自动使用项目符号(•)连接控制项
- 使用低调的颜色,显示时不喧宾夺主
- 可根据终端宽度自适应
标准视图结构
每个视图都应遵循以下统一结构:
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()
}
架构
根 TUI 模型(tui.go)
根 TUI 命令负责:
- 配置选择和验证
- 初始化 Hatchet 客户端
- 创建视图上下文
- 管理当前视图
- 将更新委派给视图
视图系统(views/ 目录)
每个视图都是一个独立文件,实现 View 接口:
view.go——基础视图接口、上下文和可复用组件{viewname}.go——各个视图的实现(例如,tasks.go)
核心原则
1. 文件结构
TUI 命令文件
- 文件:
cmd/hatchet-cli/cli/tui.go - 用途:命令设置、配置文件选择、客户端初始化、视图管理
视图文件
- 位置:
cmd/hatchet-cli/cli/tui/ - 文件:
view.go- 视图接口和基础类型{viewname}.go- 各个视图的实现
2. 视图接口
所有视图都必须实现此接口(定义于 views/view.go):
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)
}
3. 基础模型模式
使用 BaseModel 存放视图的通用字段:
// 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
}
4. 创建新视图
步骤 1:创建视图文件
创建 cmd/hatchet-cli/cli/tui/{viewname}.go:
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
}
步骤 2:在 TUI 中使用视图
根 TUI 模型负责管理视图:
// 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,
}
}
5. 客户端初始化模式
始终在 tui.go 中初始化 Hatchet 客户端:
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)
}
6. 在视图中访问客户端
可以通过视图上下文访问 Hatchet 客户端:
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,
}
}
}
7. Hatchet 主题集成
至关重要:绝不要在视图文件中硬编码颜色或样式。始终使用 cmd/hatchet-cli/cli/internal/styles 中预定义的 Hatchet 主题颜色和工具函数。
可用的主题颜色
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.SuccessBox
状态渲染
表格中的单元格着色:使用自定义的 TableWithStyleFunc 封装器,以启用单元格级样式设置。
表格中的状态渲染:
// 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.
非表格场景(标题、页脚、独立文本)的状态渲染:
// Render V1TaskStatus with proper colors
status := styles.RenderV1TaskStatus(task.Status)
// Render error messages
errorMsg := styles.RenderError(fmt.Sprintf("Error: %v", err))
为什么使用自定义的 TableWithStyleFunc?
- 标准的 bubbles 表格不支持单元格级或列级样式设置
TableWithStyleFunc封装了 bubbles 表格,并添加了 StyleFunc 支持- StyleFunc 可根据行/列索引动态设置单元格样式
- 位于
cmd/hatchet-cli/cli/tui/table_custom.go - 保留 bubbles 表格的交互功能(光标、选择、键盘导航)
表格样式
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.AdaptiveColor,以支持浅色/深色终端。
添加新的状态颜色
如需添加新的状态颜色:
- 将颜色常量添加到
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. 标准键盘控制
在所有视图中使用一致的按键映射,提供可预期的用户体验。
全局控制(由 tui.go 处理)
q或ctrl+c:退出 TUI
视图专属控制
在各个视图中实现这些控制:
- 导航:使用
↑/↓或方向键浏览列表 - 选择:使用
Enter进行选择/确认 - Tab 导航:使用
Tab/Shift+Tab切换表单字段 - 取消:使用
Esc返回/取消 - 刷新:使用
r手动刷新数据 - 筛选:使用
f打开筛选弹窗(适用时) - 调试:使用
d切换调试视图(参见“调试日志”部分) - 清除:在调试视图中使用
c清除调试日志 - Tab 视图:使用
1、2、3等,或tab/shift+tab切换标签页
重要:始终使用 RenderFooter() 在页脚中说明键盘控制方式。
9. 布局组件
至关重要:使用 view.go 中的可复用组件来显示标题、说明和页脚。参见上文的“可复用组件”部分。
标题
✅ 使用可复用组件:
header := RenderHeader("View Title", v.Ctx.ProfileName, v.Width)
❌ 不要手动创建标题:
// Bad: Don't do this
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
// ... (this violates DRY principle)
页脚
✅ 使用可复用组件:
footer := RenderFooter([]string{
"↑/↓: Navigate",
"r: Refresh",
"q: Quit",
}, v.Width)
❌ 不要手动创建页脚:
// Bad: Don't do this
footerStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
// ... (this violates DRY principle)
说明
✅ 使用可复用组件:
instructions := RenderInstructions("Your helpful instructions here", v.Width)
统计栏
可以为特定视图自定义统计栏:
statsStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
Padding(0, 1)
stats := statsStyle.Render(fmt.Sprintf(
"Total: %d | Status1: %d | Status2: %d",
total, status1Count, status2Count,
))
10. 数据集成
REST API 类型
使用以下位置生成的 REST 类型:
import "github.com/hatchet-dev/hatchet/pkg/client/rest"
常用类型:
rest.V1TaskSummaryrest.V1TaskSummaryListrest.V1WorkflowRunrest.V1WorkflowRunDetailsrest.Workerrest.WorkerRuntimeInforest.Workflowrest.APIResourceMeta
异步数据获取模式
// 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()
}
11. 模态视图
创建模态覆盖层(例如筛选表单或确认对话框)时:
- 使用
RenderHeader()显示标题已更新的页眉 - 使用
RenderInstructions()显示与模态交互相关的说明 - 显示模态内容
- 使用
RenderFooter()显示包含模态专用控件的页脚
模态结构示例:
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()
}
重要:模态应保持与常规视图相同的视觉结构(页眉、说明、内容、页脚),以确保一致性。
12. 表单集成
在视图中使用 huh 表单时:
- 设置 Hatchet 主题:
.WithTheme(styles.HatchetTheme()) - 将表单直接集成到主 tea.Program 中(不要运行单独的程序)
- 处理表单完成状态:检查
form.State == huh.StateCompleted - 将所有消息传递给表单:表单处于活动状态时,应传递所有消息(而不只是按键消息)
示例:
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
}
13. 表格组件
使用 github.com/charmbracelet/bubbles/table:
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)
14. 表格高度计算与布局优化
重要:正确计算表格高度对于充分利用终端空间至关重要。不同类型的视图需要根据表格上方和下方显示的 UI 元素采用不同的计算方式。
不同视图类型的标准高度计算
主要列表视图(例如 runs_list、workflows):
- 计算方式:
height - 12 - 考虑因素:页眉(3 行)、统计栏(2 行)、间距(2 行)、页脚(2 行)、缓冲空间(3 行)
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)
}
}
包含额外信息区段的详情视图(例如包含工作流信息和运行表格的 workflow_details):
- 计算方式:
height - 16(或根据区段大小调整) - 考虑因素:页眉(3 行)、信息区段(4 行)、区段标题(2 行)、间距(2 行)、页脚(2 行)、缓冲空间(3 行)
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)
}
}
高度计算指南
- 统计 UI 元素:列出表格上方和下方显示的所有元素
- 估算行数:
- 页眉:约 3 行(含间距)
- 统计栏:约 2 行(含间距)
- 区段标题:各约 2 行
- 信息区段:约 3–5 行,视内容而定
- 页脚:约 2 行(含间距)
- 缓冲空间:预留约 2–3 行,以确保安全
- 在不同尺寸下测试:确认表格在最小终端尺寸(80x24)下有足够空间
- 按需调整:如果表格显得拥挤,将高度偏移量减少 2–4 行
常见错误:对所有视图使用相同的高度计算方式,却不考虑额外的 UI 元素。
❌ 错误:
// Detail view with extra info section but using primary view calculation
v.table.SetHeight(msg.Height - 12) // Table will be too large, overlapping footer
✅ 正确:
// Adjust calculation based on actual UI elements in the view
v.table.SetHeight(msg.Height - 16) // Accounts for extra info section
15. 相关视图之间的列保持一致
重要:如果详情视图显示的列表在概念上与主要列表视图相似(例如工作流详情显示最近运行,与主运行列表相同),列就必须完全一致,以符合用户预期并保持一致性。
列保持一致的重要性
- 用户体验:用户希望不同视图以相同格式显示相同信息
- 认知负担:一致的列能减少切换上下文时的思考负担
- 视觉熟悉度:相同的列结构能强化视图之间的关联
示例:运行列表列
主要视图(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},
}
详情视图(workflow_details.go,显示某个工作流的最近运行):
// 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},
}
实现列一致性
实现带有相关列表的详情视图时:
- 参考主要视图:检查主要列表视图使用了哪些列
- 完全复制列结构:标题、顺序和宽度都应相同
- 保留所有列:即使在详情上下文中看似多余,也不要删除列
- 更新行填充逻辑:确保
updateTableRows()正确填充所有列
❌ 错误:
// 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
}
✅ 正确:
// 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
}
16. 视图导航与模态选择器
TUI 使用导航栈系统逐层查看详情,并使用模态选择器在主要视图之间切换。
导航栈模式
根 TUI 模型维护一个 viewStack,用于返回导航:
type tuiModel struct {
currentView tui.View
viewStack []tui.View // Stack for back navigation
// ...
}
导航到详情视图:
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()
返回导航:
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, nil
在详情视图中(处理 Esc 键以返回导航):
case tea.KeyMsg:
switch msg.String() {
case "esc":
// Navigate back to previous view
return v, NewNavigateBackMsg()
}
模态视图选择器模式
模态选择器支持使用 Shift+Tab 在主要视图之间切换:
打开模态框:
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
}
模态框导航(支持 Tab、方向键和 vim 按键):
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
}
渲染模态框:
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()
}
关键原则:
- 导航栈:用于层级导航(列表 → 详情 → 返回)
- 模态选择器:用于在顶层视图之间切换
- 主要视图检查:仅当不在详情视图中时才允许切换视图
- 一致的按键绑定:
Shift+Tab:打开视图选择器Esc:返回(在详情视图中)或取消(在模态框中)Enter:选择项目或确认操作- 方向键/vim 按键:在列表和模态框中导航
常用模式
格式化工具
时长格式化
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 截断
func truncateID(id string, length int) string {
if len(id) > length {
return id[:length]
}
return id
}
状态渲染
重要:不要手动设置状态样式。请使用状态工具函数:
// 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
自动刷新模式
// 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())
调试日志模式
重要:对于会调用 API 或进行复杂状态管理的视图,请使用环形缓冲区实现调试日志系统,以防止内存泄漏。
步骤 1:创建调试日志记录器(如果尚不存在)
创建 cmd/hatchet-cli/cli/tui/debug.go:
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
}
步骤 2:在视图中集成调试日志记录器
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
}
步骤 3:在视图中添加调试日志
// 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}
}
}
步骤 4:添加切换按键处理程序
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
}
步骤 5:实现调试视图渲染
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()
}
步骤 6:更新页脚控件
在常规视图页脚中添加调试控件:
controls := footerStyle.Render("↑/↓: Navigate | r: Refresh | d: Debug | q: Quit")
优点:
- 固定大小的环形缓冲区可防止内存泄漏
- 通过互斥锁保护实现线程安全
- 无需重启 TUI 即可开启或关闭
- 有助于诊断 API 问题和状态变化
- 不查看日志时不会影响性能
测试方法
生成虚拟数据
开发过程中,可在视图文件中创建虚拟数据生成器:
func generateDummyData() []YourDataType {
now := time.Now()
return []YourDataType{
{
Field1: "value1",
Field2: "value2",
CreatedAt: now.Add(-5 * time.Minute),
},
// ... more dummy items
}
}
示例参考
文件结构示例
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/tasks.go。
编译与测试
至关重要:在认为工作完成之前,务必确保 CLI 二进制文件可以编译。
编译检查
实现或修改任何视图后:
# Build the CLI binary
go build -o /tmp/hatchet-test ./cmd/hatchet-cli
# Check for errors
echo $? # Should be 0 for success
常见编译问题
-
UUID 类型不匹配
// ❌ 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), ...) -
必需的导入
import ( "github.com/google/uuid" openapi_types "github.com/oapi-codegen/runtime/types" ) -
API 参数的类型转换
- offset/limit 使用
*int64,而非*int - Since 参数使用
time.Time,而非*time.Time(请检查生成的类型) - tenant 和 workflow ID 使用
openapi_types.UUID - 在
pkg/client/rest/gen.go中检查确切的参数类型
- offset/limit 使用
-
指针辅助函数
func int64Ptr(i int64) *int64 { return &i }
测试工作流
-
编译测试
go build -o /tmp/hatchet-test ./cmd/hatchet-cli -
Lint 检查
构建成功后,运行 lint 检查:
task pre-commit-run持续运行此命令,直到检查通过。继续之前,先修复报告的所有 lint 问题。
-
基本功能测试
# Test with profile selection /tmp/hatchet-test tui # Test with specific profile /tmp/hatchet-test tui --profile your-profile -
错误处理测试
- 尝试在未配置配置文件的情况下运行
- 尝试使用无效的配置文件
- 测试键盘控制(q、r、方向键)
-
视觉与布局测试
实现新视图时:
- 在各种终端尺寸下进行测试(最小为 80x24)
- 确保页眉和页脚始终可见
- 确认操作说明清晰易懂
- 检查导航控件是否与其他视图保持一致
- 在浅色和深色终端背景下进行测试
- 确认所有可复用组件都能正确渲染
新建 TUI 视图检查清单
开始之前
- 找到对应的前端视图,位于
frontend/app/src/pages/main/v1/ - 确认前端中的列结构和 API 调用
- 记录使用的确切 API 端点和参数
创建视图
- 在
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 集成
- 如有需要,将租户 ID 解析为 UUID
- 使用正确的参数类型(
*int64、time.Time等) - 处理 API 响应错误
- 格式化数据以供表格显示
- 添加加载状态和错误显示
集成与测试
- 在
tui.go中导入视图 - 更新
newTUIModel(),使其创建你的视图实例 - 编译 CLI 二进制文件(
go build ./cmd/hatchet-cli) - 修复所有编译错误
- 使用真实配置文件测试基本功能
- 测试错误情况(无配置文件、无效配置文件)
- 测试键盘控制(q、r、方向键)
- 更新 TUI 命令文档
最佳实践
- **重要:**使用可复用组件(
RenderHeader、RenderInstructions、RenderFooter) - 切勿复制粘贴页眉/页脚样式代码——这违反 DRY 原则
- 将视图逻辑隔离在视图文件中
- 使用
ViewContext访问客户端和配置文件信息 - 妥善处理所有消息(对于未处理的消息,返回
v, nil) - 渲染前始终检查
v.Width == 0 - 与其他视图保持样式一致(使用 Hatchet 主题颜色)
- 使用
RenderFooter()在页脚中说明所有键盘控制 - 遵循标准视图结构(页眉、操作说明、内容、页脚)
- 提交前务必确认编译通过
实现后
- 更新此技能文档,补充发现的任何新模式或经验
- 记录遇到的问题及其解决方案
- 在相应章节中添加新的实用函数或模式
- 确认所有代码示例和文件路径都准确无误
经验与更新
本节记录近期的经验和更新,以确保内容准确。
最近更新
- 2026-01-10:基于 workers 视图实现和 Bug 修复进行了重大更新:
- 添加了 workers 列表视图和 worker 详情视图作为参考实现
- 更新了常见 REST API 类型列表,加入 Worker、WorkerRuntimeInfo、Workflow
- 记录了详情视图标题栏模式(在标题中显示具体资源名称)
- 添加了关于使用多选表单和自定义键映射进行筛选的章节
- 记录了使用 TableWithStyleFunc 封装器为表格单元格单独设置样式的方法
- 添加了在详情视图中渲染状态徽章的示例
- 记录了导航消息(NavigateToWorkerMsg 模式)
- 添加了列对齐最佳实践(使标题格式字符串与行渲染保持一致)
- 工作流 TUI 实现更新:
- 更新了标题组件文档:所有标题(主视图、详情视图、模态视图)现在都使用高亮颜色,以保持一致
- 为主视图添加了
RenderHeaderWithViewIndicator()(仅显示视图名称,避免重复) - 添加了第 14 节:表格高度计算与布局优化(height - 12 与 height - 16)
- 添加了第 15 节:相关视图间的列一致性(对 UX 至关重要)
- 添加了第 16 节:视图导航与模态选择器模式
- 记录了支持 Shift+Tab 和方向键的模态选择器
- 记录了用于详情视图逐层深入的导航栈模式
- 2026-01-09:添加了自我更新说明和验证清单
- 初始化文档,纳入全面的 TUI 视图构建指南
已知问题与解决方案
问题:表格列未对齐
问题:由于格式字符串宽度不匹配,标题列与表格行未对齐。
示例:在运行详情的任务标签页中,标题使用了 %-3s 作为选择器列,但行只渲染了 2 个字符(“▸ ”或“ ”),导致状态列及其后的所有列都未对齐。
解决方案:确保标题格式字符串的宽度与行渲染完全一致:
// 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
}
预防:始终准确计算行中渲染的字符数,并使标题格式宽度与之精确匹配。
问题:详情视图标题过于笼统
问题:详情视图显示的是“Task Details”或“Workflow Run Details”等通用标题,未标明当前查看的具体资源。
解决方案:在标题中加入资源名称:
// 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)
}
模式:所有详情视图均使用格式 "{View Type} Details: {Resource Name}"。
问题:筛选表单导航冲突
问题:用于切换视图的全局 Shift+Tab 处理程序与表单导航冲突,导致无法在筛选模态框中使用 Tab/Shift+Tab。
解决方案:在检查全局按键处理程序之前,先处理筛选表单消息:
// 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
}
}
模式:处理全局键盘快捷键之前,始终先委托给当前激活的模态框/表单组件。
问题:筛选后的 workers 未反映在导航中
问题:按 Enter 键导航到 worker 详情时,代码使用了未筛选的 v.workers 列表,而不是筛选后的列表,导致光标索引与显示的行不匹配。
解决方案:使用筛选后/显示的列表进行导航:
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)
}
}
模式:渲染和导航始终使用相同的数据源。如果为 StyleFunc 缓存了筛选后的数据,导航时也要使用该缓存数据。
未来改进
(本节将记录 TUI 系统或本文档可能的改进)