mcpField
B
build-tui-view

build-tui-view

github.com/hatchet-dev开发8.2k61 次安装今天更新
代码仓库

关于 build-tui-view

build-tui-view 是用于借助 bubbletea 和 lipgloss 创建、维护 Hatchet CLI 终端界面的技能,涵盖视图架构、可复用组件,以及对照前端实现并校正文档的方法。

主要功能

  1. 01bubbletea 与 lipgloss 视图指南
  2. 02Hatchet CLI 模块化视图架构
  3. 03可复用的标题、说明和页脚
  4. 04前端与 API 参考流程
  5. 05自我修正和文档核查

适用场景

  1. 01实现 Hatchet CLI 列表视图
  2. 02用共享组件构建详情视图
  3. 03使 TUI 列与前端数据保持一致
  4. 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 调用。

自更新技能说明

关键——请先阅读:此技能文档旨在持续改进并保持准确。

何时更新此技能

遇到以下情况时,必须更新此技能文件:

  1. 发现不准确之处

    • 发现文件路径或目录结构有误
    • 代码示例无法编译,或与实际实现不符
    • API 签名已更改
    • 引用的文件在指定位置不存在
  2. 学习新模式

    • 实现新视图时发现了更好的方法
    • 用户向你介绍了新的约定或模式
    • 发现了值得记录的可复用模式
    • 发现了应该提醒他人注意的常见问题
  3. 发现信息缺失

    • 需要的信息未在此处记录
    • 代码库中添加了新组件或工具
    • 采用了新的 bubbletea/lipgloss 模式
  4. 用户纠正

    • 用户纠正了本文档中的任何信息
    • 用户提供了更新后的方法或约定
    • 用户指出了过时的信息
如何更新此技能

更新此技能时:

  1. 添加前先核实:将路径、代码和 API 签名添加到本文档前,务必先对照实际代码库进行核实
  2. 使用 Read/Glob/Grep:检查实际文件,确保信息准确
  3. 测试代码示例:确保代码示例可以编译,并遵循当前模式
  4. 明确具体:提供准确的文件路径、函数签名和可运行的代码示例
  5. 立即更新:发现不准确之处后立即更新,不要等到会话结束
  6. 保留结构:保持现有文档结构和格式
  7. 补充背景:添加新章节时,说明推荐此模式的原因
核实清单

使用此技能中的信息前,请核实:

  • 文件路径存在且正确
  • 代码示例与当前实现一致
  • API 签名与生成的 REST 客户端一致
  • 对可复用组件的引用正确
  • 目录结构准确
自我纠正流程

如果工作中发现不准确之处:

  1. 立即记下问题
  2. 阅读实际文件,核实正确信息
  3. 更正此技能文档
  4. 使用更正后的信息继续完成用户的任务

请记住:此技能应是一份持续完善的文档,每次使用后都能变得更加准确、全面。

项目背景

  • 框架: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 视图之前,先找到对应的前端视图,以了解:

  1. 列结构和名称
  2. API 端点和查询参数
  3. 使用的数据类型和字段
  4. 筛选和排序逻辑
查找前端参考的流程:
  1. 定位前端视图

    # 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
    
  2. 研究列定义

    • 查找 {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",
    };
    
  3. 识别数据 Hook

    • 在 hooks/ 目录中查找 use-{feature}.tsx 文件
    • 这些文件包含 API 查询逻辑
    • 示例:frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-runs.tsx
  4. 查找 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,
      }),
    }
    
  5. 映射到 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,
      },
    )
    
示例:根据前端参考实现任务视图
  1. 前端结构:frontend/app/src/pages/main/v1/workflow-runs-v1/

    • 列:task-runs-columns.tsx
    • Hook:use-runs.tsx
    • 表格:runs-table.tsx
  2. 提取列名称:

    taskName, status, workflow, createdAt, startedAt, duration;
    
  3. 识别 API 调用:

    queries.v1WorkflowRuns.list(tenantId, {
      offset,
      limit,
      statuses,
      workflow_ids,
      since,
      until,
      only_tasks: true,
    });
    
  4. 在 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 命令负责:

  1. 配置选择和验证
  2. 初始化 Hatchet 客户端
  3. 创建视图上下文
  4. 管理当前视图
  5. 将更新委派给视图
视图系统(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,以支持浅色/深色终端。

添加新的状态颜色

如需添加新的状态颜色:

  1. 将颜色常量添加到 cmd/hatchet-cli/cli/internal/styles/styles.go
  2. 在 cmd/hatchet-cli/cli/internal/styles/status.go 中创建或更新工具函数
  3. 参考 frontend/app/src/components/v1/ui/badge.tsx 中的前端徽标变体,确定颜色值
  4. 使用自适应颜色,以支持浅色/深色终端
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.V1TaskSummary
  • rest.V1TaskSummaryList
  • rest.V1WorkflowRun
  • rest.V1WorkflowRunDetails
  • rest.Worker
  • rest.WorkerRuntimeInfo
  • rest.Workflow
  • rest.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. 模态视图

创建模态覆盖层(例如筛选表单或确认对话框)时:

  1. 使用 RenderHeader() 显示标题已更新的页眉
  2. 使用 RenderInstructions() 显示与模态交互相关的说明
  3. 显示模态内容
  4. 使用 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 表单时:

  1. 设置 Hatchet 主题:.WithTheme(styles.HatchetTheme())
  2. 将表单直接集成到主 tea.Program 中(不要运行单独的程序)
  3. 处理表单完成状态:检查 form.State == huh.StateCompleted
  4. 将所有消息传递给表单:表单处于活动状态时,应传递所有消息(而不只是按键消息)

示例:

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)
    }
}
高度计算指南
  1. 统计 UI 元素:列出表格上方和下方显示的所有元素
  2. 估算行数:
    • 页眉:约 3 行(含间距)
    • 统计栏:约 2 行(含间距)
    • 区段标题:各约 2 行
    • 信息区段:约 3–5 行,视内容而定
    • 页脚:约 2 行(含间距)
    • 缓冲空间:预留约 2–3 行,以确保安全
  3. 在不同尺寸下测试:确认表格在最小终端尺寸(80x24)下有足够空间
  4. 按需调整:如果表格显得拥挤,将高度偏移量减少 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. 相关视图之间的列保持一致

重要:如果详情视图显示的列表在概念上与主要列表视图相似(例如工作流详情显示最近运行,与主运行列表相同),列就必须完全一致,以符合用户预期并保持一致性。

列保持一致的重要性
  1. 用户体验:用户希望不同视图以相同格式显示相同信息
  2. 认知负担:一致的列能减少切换上下文时的思考负担
  3. 视觉熟悉度:相同的列结构能强化视图之间的关联
示例:运行列表列

主要视图(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},
}
实现列一致性

实现带有相关列表的详情视图时:

  1. 参考主要视图:检查主要列表视图使用了哪些列
  2. 完全复制列结构:标题、顺序和宽度都应相同
  3. 保留所有列:即使在详情上下文中看似多余,也不要删除列
  4. 更新行填充逻辑:确保 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()
}

关键原则:

  1. 导航栈:用于层级导航(列表 → 详情 → 返回)
  2. 模态选择器:用于在顶层视图之间切换
  3. 主要视图检查:仅当不在详情视图中时才允许切换视图
  4. 一致的按键绑定:
    • 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
常见编译问题
  1. 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), ...)
    
  2. 必需的导入

    import (
        "github.com/google/uuid"
        openapi_types "github.com/oapi-codegen/runtime/types"
    )
    
  3. API 参数的类型转换

    • offset/limit 使用 *int64,而非 *int
    • Since 参数使用 time.Time,而非 *time.Time(请检查生成的类型)
    • tenant 和 workflow ID 使用 openapi_types.UUID
    • 在 pkg/client/rest/gen.go 中检查确切的参数类型
  4. 指针辅助函数

    func int64Ptr(i int64) *int64 {
        return &i
    }
    
测试工作流
  1. 编译测试

    go build -o /tmp/hatchet-test ./cmd/hatchet-cli
    
  2. Lint 检查

    构建成功后,运行 lint 检查:

    task pre-commit-run
    

    持续运行此命令,直到检查通过。继续之前,先修复报告的所有 lint 问题。

  3. 基本功能测试

    # Test with profile selection
    /tmp/hatchet-test tui
    
    # Test with specific profile
    /tmp/hatchet-test tui --profile your-profile
    
  4. 错误处理测试

    • 尝试在未配置配置文件的情况下运行
    • 尝试使用无效的配置文件
    • 测试键盘控制(q、r、方向键)
  5. 视觉与布局测试

    实现新视图时:

    • 在各种终端尺寸下进行测试(最小为 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 系统或本文档可能的改进)

评论

支持 Markdown