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
views/
directory.
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:
  1. 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
  2. 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
  3. 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
  4. 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
在以下情况下,您必须更新本技能文件:
  1. 发现不准确内容
    • 当您发现错误的文件路径或目录结构时
    • 当代码示例无法编译或与实际实现不匹配时
    • 当API签名发生变化时
    • 当引用的文件不存在于指定位置时
  2. 学习到新模式
    • 当您实现新视图并发现更优方法时
    • 当用户向您传授新约定或模式时
    • 当您发现应记录的可复用模式时
    • 当您发现应提醒的常见陷阱时
  3. 发现缺失信息
    • 当您需要此处未记录的信息时
    • 当代码库中添加了新组件或工具时
    • 当采用了新的bubbletea/lipgloss模式时
  4. 用户修正
    • 当用户修正了本文档中的任何信息时
    • 当用户提供了更新后的方法或约定时
    • 当用户指出过时信息时

How to Update This Skill

如何更新本技能

When updating this skill:
  1. Verify Before Adding: Always verify paths, code, and API signatures against the actual codebase before adding to this document
  2. Use Read/Glob/Grep: Check the actual files to ensure accuracy
  3. Test Code Examples: Ensure code examples compile and follow current patterns
  4. Be Specific: Include exact file paths, function signatures, and working code examples
  5. Update Immediately: Make updates as soon as inaccuracies are discovered, not at the end of a session
  6. Preserve Structure: Maintain the existing document structure and formatting
  7. Add Context: When adding new sections, explain why the pattern is recommended
更新本技能时:
  1. 添加前验证:在添加到本文档之前,请始终对照实际代码库验证路径、代码和API签名
  2. 使用Read/Glob/Grep:检查实际文件以确保准确性
  3. 测试代码示例:确保代码示例可编译并遵循当前模式
  4. 保持具体:包含确切的文件路径、函数签名和可运行的代码示例
  5. 立即更新:发现不准确内容后立即更新,不要等到会话结束
  6. 保留结构:维护现有文档的结构和格式
  7. 添加上下文:添加新章节时,说明推荐该模式的原因

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:
  1. Immediately note the issue
  2. Verify the correct information by reading the actual files
  3. Update this skill document with the correction
  4. 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.
如果您在工作中发现不准确内容:
  1. 立即记录问题
  2. 通过读取实际文件验证正确信息
  3. 更新本技能文档进行修正
  4. 使用修正后的信息继续完成用户任务
请记住:本技能应是一个活文档,每次使用都要变得更准确、更全面。

Project Context

项目上下文

  • Framework: bubbletea (TUI framework)
  • Styling: lipgloss (style definitions)
  • TUI Command Location:
    cmd/hatchet-cli/cli/tui.go
  • Views Location:
    cmd/hatchet-cli/cli/tui/
    directory
  • Theme: Pre-defined Hatchet theme in
    cmd/hatchet-cli/cli/internal/styles/styles.go
  • Frontend Reference:
    frontend/app/src/pages/main/v1/
    directory
  • 框架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/
    目录

Finding Frontend Prior Art

查找前端已有实现

CRITICAL FIRST STEP: Before implementing any TUI view, locate the corresponding frontend view to understand:
  1. Column structure and names
  2. API endpoints and query parameters
  3. Data types and fields used
  4. Filtering and sorting logic
关键第一步:在实现任何TUI视图之前,请找到对应的前端视图以了解:
  1. 列结构和名称
  2. API端点和查询参数
  3. 使用的数据类型和字段
  4. 过滤和排序逻辑

Process for Finding Frontend Reference:

查找前端参考的流程:

  1. Locate the Frontend View
    bash
    # 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. 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
    typescript
    export const TaskRunColumn = {
      taskName: "Task Name",
      status: "Status",
      workflow: "Workflow",
      createdAt: "Created At",
      startedAt: "Started At",
      duration: "Duration",
    };
  3. Identify the Data Hook
    • Look for
      use-{feature}.tsx
      files in the
      hooks/
      directory
    • These contain the API query logic
    • Example:
      frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-runs.tsx
  4. Find the API Query
    • Check
      frontend/app/src/lib/api/queries.ts
      for the query definition
    • Note the endpoint name and parameters
    • Example:
    typescript
    v1WorkflowRuns: {
      list: (tenant: string, query: V2ListWorkflowRunsQuery) => ({
        queryKey: ['v1:workflow-run:list', tenant, query],
        queryFn: async () => (await api.v1WorkflowRunList(tenant, query)).data,
      }),
    }
  5. Map to Go REST Client
    • The frontend
      api.v1WorkflowRunList()
      maps to Go's
      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,
      },
    )
  1. 定位前端视图
    bash
    # 导航到前端页面
    cd frontend/app/src/pages/main/v1/
    
    # 查找与您的功能相关的视图(例如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
    typescript
    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
      中的查询定义
    • 记录端点名称和参数
    • 示例:
    typescript
    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结构体参数
    • 示例映射:
    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视图

  1. Frontend Structure:
    frontend/app/src/pages/main/v1/workflow-runs-v1/
    • Columns:
      task-runs-columns.tsx
    • Hook:
      use-runs.tsx
    • Table:
      runs-table.tsx
  2. Extract Column Names:
    typescript
    taskName, status, workflow, createdAt, startedAt, duration;
  3. Identify API Call:
    typescript
    queries.v1WorkflowRuns.list(tenantId, {
      offset,
      limit,
      statuses,
      workflow_ids,
      since,
      until,
      only_tasks: true,
    });
  4. 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,
      },
    )
  1. 前端结构
    frontend/app/src/pages/main/v1/workflow-runs-v1/
    • 列:
      task-runs-columns.tsx
    • Hook:
      use-runs.tsx
    • 表格:
      runs-table.tsx
  2. 提取列名称
    typescript
    taskName, status, workflow, createdAt, startedAt, duration;
  3. 识别API调用
    typescript
    queries.v1WorkflowRuns.list(tenantId, {
      offset,
      limit,
      statuses,
      workflow_ids,
      since,
      until,
      only_tasks: true,
    });
  4. 在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
view.go
to ensure consistency across the application. DO NOT copy-paste header/footer styling code.
重要提示:所有TUI视图必须使用
view.go
中定义的标准化可复用组件,以确保应用的一致性。请勿复制粘贴页眉/页脚样式代码。

Header Component

页眉组件

CRITICAL: ALL headers throughout the TUI use the magenta highlight color (
styles.HighlightColor
) for the title to provide consistent visual emphasis across all views (primary views, detail views, modals, etc.).
关键提示:所有TUI中的页眉都使用洋红色高亮色(
styles.HighlightColor
)显示标题,以在所有视图(主视图、详情视图、模态框等)中提供一致的视觉强调。

For Detail Views and Modals

详情视图和模态框

Always use
RenderHeader()
for detail views, modals, and secondary screens:
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
RenderHeaderWithViewIndicator()
for primary/list views:
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 (
    styles.HighlightColor
    ) - consistent across ALL views
  • 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
RenderInstructions()
to display contextual help text:
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
RenderFooter()
for navigation/control hints:
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模型(
tui.go

The root TUI command is responsible for:
  1. Profile selection and validation
  2. Initializing the Hatchet client
  3. Creating the view context
  4. Managing the current view
  5. Delegating updates to views
根TUI命令负责:
  1. 配置文件选择和验证
  2. 初始化Hatchet客户端
  3. 创建视图上下文
  4. 管理当前视图
  5. 将更新委托给视图

View System (
views/
directory)

视图系统(
views/
目录)

Each view is a separate file that implements the
View
interface:
  • view.go
    - Base view interface, context, and reusable components
  • {viewname}.go
    - Individual view implementations (e.g.,
    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.go
      - View interface and base types
    • {viewname}.go
      - Individual view implementations
  • 位置:
    cmd/hatchet-cli/cli/tui/
  • 文件:
    • view.go
      - 视图接口和基础类型
    • {viewname}.go
      - 各个视图的实现

2. View Interface

2. 视图接口

All views must implement this interface (defined in
views/view.go
):
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)
}
所有视图必须实现此接口(定义在
views/view.go
中):
go
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
BaseModel
for common view fields:
go
// 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
}
使用
BaseModel
存储通用视图字段:
go
// 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}.go
:
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
}
创建
cmd/hatchet-cli/cli/tui/{viewname}.go
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
	// 视图特定字段
}

// 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.go
:
go
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)
}
请始终在
tui.go
中初始化Hatchet客户端:
go
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
.
关键提示:请勿在视图文件中硬编码颜色或样式。请始终使用
cmd/hatchet-cli/cli/internal/styles
中预定义的Hatchet主题颜色和工具。

Available 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.SuccessBox
go
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.SuccessBox

Status Rendering

状态渲染

Per-Cell Coloring in Tables: Use the custom
TableWithStyleFunc
wrapper to enable per-cell styling.
For 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
  • TableWithStyleFunc
    wraps bubbles table and adds StyleFunc support
  • 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表格不支持单元格级或列级样式
  • TableWithStyleFunc
    包装bubbles表格并添加StyleFunc支持
  • 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
lipgloss.AdaptiveColor
even for basic colors like white/black to support light/dark terminals.
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)
注意:即使是白色/黑色等基础颜色,也请使用
lipgloss.AdaptiveColor
以支持亮色/暗色终端。

Adding New Status Colors

添加新状态颜色

If you need to add new status colors:
  1. Add the color constants to
    cmd/hatchet-cli/cli/internal/styles/styles.go
  2. Create or update the utility function in
    cmd/hatchet-cli/cli/internal/styles/status.go
  3. Reference the frontend badge variants in
    frontend/app/src/components/v1/ui/badge.tsx
    for color values
  4. Use adaptive colors for light/dark terminal support
如果您需要添加新的状态颜色:
  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. 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中处理)

  • q
    or
    ctrl+c
    : Quit the TUI
  • q
    ctrl+c
    :退出TUI

View-Specific Controls

视图特定控制

Implement these in individual views:
  • Navigation:
    ↑/↓
    or arrow keys for list navigation
  • Selection:
    Enter
    to select/confirm
  • Tab Navigation:
    Tab
    /
    Shift+Tab
    for form fields
  • Cancel:
    Esc
    to go back/cancel
  • Refresh:
    r
    to manually refresh data
  • Filter:
    f
    to open filter modal (where applicable)
  • Debug:
    d
    to toggle debug view (see Debug Logging section)
  • Clear:
    c
    to clear debug logs (when in debug view)
  • Tab Views:
    1
    ,
    2
    ,
    3
    , etc. or
    tab
    /
    shift+tab
    for switching tabs
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
view.go
for headers, instructions, and footers. See "Reusable Components" section above.
关键提示:页眉、说明和页脚请使用
view.go
中的可复用组件。请参阅上方的“可复用组件”部分。

Header

页眉

✅ 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.V1TaskSummary
  • rest.V1TaskSummaryList
  • rest.V1WorkflowRun
  • rest.V1WorkflowRunDetails
  • rest.Worker
  • rest.WorkerRuntimeInfo
  • rest.Workflow
  • rest.APIResourceMeta
使用生成的REST类型:
go
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

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):
  1. Still show the header with updated title using
    RenderHeader()
  2. Show instructions specific to the modal interaction using
    RenderInstructions()
  3. Show the modal content
  4. 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.
创建模态覆盖层(如过滤表单或确认对话框)时:
  1. 仍需使用
    RenderHeader()
    显示更新后的页眉
  2. 使用
    RenderInstructions()
    显示特定于模态交互的说明
  3. 显示模态内容
  4. 使用
    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
huh
forms in views:
  1. Set the Hatchet theme:
    .WithTheme(styles.HatchetTheme())
  2. Integrate forms directly into the main tea.Program (don't run separate programs)
  3. Handle form completion by checking
    form.State == huh.StateCompleted
  4. 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
表单时:
  1. 设置Hatchet主题
    .WithTheme(styles.HatchetTheme())
  2. 将表单直接集成到主tea.Program中(不要运行单独的程序)
  3. 通过检查
    form.State == huh.StateCompleted
    处理表单完成事件
  4. 表单激活时传递所有消息给表单(不仅仅是按键消息)
示例
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/table
:
go
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/table
go
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:
    height - 16
    (or adjust based on info section size)
  • 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

高度计算指南

  1. Count Your UI Elements: List all elements that appear above and below the table
  2. 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
  3. Test at Different Sizes: Verify the table has adequate space at minimum terminal size (80x24)
  4. 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
  1. 统计您的UI元素:列出表格上下显示的所有元素
  2. 估算行数
    • 页眉:约3行(含间距)
    • 统计栏:约2行(含间距)
    • 区域页眉:每个约2行
    • 信息区域:约3-5行(取决于内容)
    • 页脚:约2行(含间距)
    • 缓冲区:约2-3行(安全起见)
  3. 在不同尺寸下测试:在最小终端尺寸(80x24)下验证表格有足够的空间
  4. 如有需要请迭代调整:如果表格感觉拥挤,将高度偏移减少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

列一致性的重要性

  1. User Experience: Users expect the same information in the same format across views
  2. Cognitive Load: Consistent columns reduce mental overhead when switching contexts
  3. Visual Familiarity: Same column structure reinforces the relationship between views
  1. 用户体验:用户期望在不同视图中以相同格式获取相同信息
  2. 认知负荷:一致的列减少切换上下文时的脑力负担
  3. 视觉熟悉度:相同的列结构强化视图之间的关联

Example: Runs List Columns

示例:运行列表列

Primary View (
runs_list.go
):
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},
}
Detail View (
workflow_details.go
showing recent runs for a workflow):
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},
}
主视图
runs_list.go
):
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
显示工作流的最近运行):
go
// 必须与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:
  1. Reference the primary view: Check which columns the primary list view uses
  2. Copy the column structure exactly: Same titles, same order, same widths
  3. Keep all columns: Don't remove columns even if they seem redundant in the detail context
  4. Update row population: Ensure
    updateTableRows()
    populates all columns correctly
❌ 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
}
实现带有相关列表的详情视图时:
  1. 参考主视图:检查主列表视图使用的列
  2. 完全复制列结构:相同的标题、顺序和宽度
  3. 保留所有列:即使在详情上下文中看似冗余,也不要删除列
  4. 更新行填充:确保
    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
viewStack
for back navigation:
go
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, nil
In 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模型维护一个
viewStack
用于返回导航:
go
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+Tab
:
Opening 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:
  1. Navigation Stack: Use for hierarchical navigation (list → detail → back)
  2. Modal Selector: Use for switching between top-level views
  3. Primary View Check: Only allow view switching when not in a detail view
  4. Consistent Key Bindings:
    • Shift+Tab
      : Open view selector
    • Esc
      : Go back (in detail views) or cancel (in modals)
    • Enter
      : Select item or confirm action
    • 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()
}
关键原则
  1. 导航栈:用于层级导航(列表 → 详情 → 返回)
  2. 模态选择器:用于在顶级视图之间切换
  3. 主视图检查:仅在非详情视图中允许切换视图
  4. 一致的按键绑定
    • 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.go
:
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
}
创建
cmd/hatchet-cli/cli/tui/debug.go
go
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
cmd/hatchet-cli/cli/tui/tasks.go
for a complete implementation.
请参阅
cmd/hatchet-cli/cli/tui/tasks.go
获取完整实现。

Compilation 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
undefined

Build 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
undefined
echo $? # 成功时应为0
undefined

Common Compilation Issues

常见编译问题

  1. UUID Type Mismatches
    go
    // ❌ 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. Required Imports
    go
    import (
        "github.com/google/uuid"
        openapi_types "github.com/oapi-codegen/runtime/types"
    )
  3. Type Conversions for API Params
    • *int64
      not
      *int
      for offset/limit
    • time.Time
      not
      *time.Time
      for Since parameter (check the generated types)
    • openapi_types.UUID
      for tenant and workflow IDs
    • Check
      pkg/client/rest/gen.go
      for exact parameter types
  4. Pointer Helper Functions
    go
    func int64Ptr(i int64) *int64 {
        return &i
    }
  1. 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), ...)
  2. 必要的导入
    go
    import (
        "github.com/google/uuid"
        openapi_types "github.com/oapi-codegen/runtime/types"
    )
  3. API参数的类型转换
    • offset/limit使用
      *int64
      而非
      *int
    • Since参数使用
      time.Time
      而非
      *time.Time
      (请检查生成的类型)
    • 租户和工作流ID使用
      openapi_types.UUID
    • 请检查
      pkg/client/rest/gen.go
      获取确切的参数类型
  4. 指针辅助函数
    go
    func int64Ptr(i int64) *int64 {
        return &i
    }

Testing Workflow

测试流程

  1. Compilation Test
    bash
    go build -o /tmp/hatchet-test ./cmd/hatchet-cli
  2. Linting Test
    After the build succeeds, run the linting checks:
    bash
    task pre-commit-run
    Continue running this command until it succeeds. Fix any linting issues that are reported before proceeding.
  3. Basic Functionality Test
    bash
    # Test with profile selection
    /tmp/hatchet-test tui
    
    # Test with specific profile
    /tmp/hatchet-test tui --profile your-profile
  4. Error Handling Test
    • Try without profiles configured
    • Try with invalid profile
    • Test keyboard controls (q, r, arrows)
  5. Visual/Layout Testing
    When 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
  1. 编译测试
    bash
    go build -o /tmp/hatchet-test ./cmd/hatchet-cli
  2. ** lint测试**
    构建成功后,运行lint检查:
    bash
    task pre-commit-run
    持续运行此命令直到成功。在继续之前,请修复所有报告的lint问题。
  3. 基本功能测试
    bash
    # 测试配置文件选择
    /tmp/hatchet-test tui
    
    # 使用特定配置文件测试
    /tmp/hatchet-test tui --profile your-profile
  4. 错误处理测试
    • 尝试在未配置配置文件的情况下运行
    • 尝试使用无效配置文件
    • 测试键盘控件(q、r、箭头键)
  5. 视觉/布局测试
    实现新视图时:
    • 在各种终端尺寸下测试(最小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
    uuid
    and
    openapi_types
    if needed)
  • Define view struct that embeds
    BaseModel
  • Create
    NewYourView(ctx ViewContext)
    constructor
  • Implement
    Init()
    method
  • Implement
    Update(msg tea.Msg)
    method
  • Implement
    View()
    method following standard structure
  • Implement
    SetSize(width, height int)
    method
  • 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
    ,
    time.Time
    , etc.)
  • 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
    newTUIModel()
    to instantiate your view
  • 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
    ViewContext
    to access client and profile info
  • Handle all messages gracefully (return
    v, nil
    for unhandled)
  • Always check
    v.Width == 0
    before rendering
  • 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
        RenderHeaderWithViewIndicator()
        for primary views (shows just view name, non-repetitive)
      • 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
%-3s
for selector column but rows only rendered 2 characters ("▸ " or " "), causing status column and all subsequent columns to be misaligned.
Solution: 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标签页中,页眉对选择器列使用
%-3s
,但行仅渲染2个字符("▸ "或" "),导致状态列及后续所有列对齐错误。
解决方案:确保页眉格式字符串宽度与行渲染完全匹配:
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
"{View Type} Details: {Resource Name}"
for all detail views.
问题:详情视图显示通用标题,如"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
v.workers
list instead of filtered list, causing cursor index mismatch with displayed rows.
Solution: 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系统或本技能文档的潜在改进)