mcpField
B
build-tui-view

build-tui-view

github.com/hatchet-dev開発8.2k61 回インストール今日更新
リポジトリ

build-tui-view について

build-tui-viewは、bubbleteaとlipglossを使ってHatchet CLIのTUIビューを作成・保守するスキルです。ビュー構成や再利用可能なUI部品に加え、フロントエンドを参照して手順を検証する方法を説明します。

主な機能

  1. 01bubbleteaとlipglossによるビュー作成ガイド
  2. 02Hatchet CLIのモジュール式ビュー構成
  3. 03ヘッダー・説明・フッターの共通部品
  4. 04フロントエンドとAPIの参照手順
  5. 05自己修正とドキュメント検証

活用シーン

  1. 01Hatchet CLIの一覧ビュー実装
  2. 02共通部品を使った詳細ビュー作成
  3. 03TUIの列やデータをフロントエンドに合わせる
  4. 04コード変更を反映したガイド更新

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/hatchet-dev/hatchet --skill build-tui-view

インストール後、Claude Code で/build-tui-viewを入力して読み込まれたか確認します。

詳細

📝 自己更新ドキュメント:このスキルは、不正確な情報が見つかったり、新しいパターンを学んだりしたときに、自動的に更新されます。実際のコードベースと照らし合わせて情報を必ず確認し、必要に応じてこのファイルを更新してください。

概要

このスキルでは、bubbletea と lipgloss を使用して、Hatchet CLI でターミナルユーザーインターフェース(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. データフックを特定する

    • 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
    • フック: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)で表示します。これはすべてのビューで一貫しています
  • 右側にロゴ(テキスト形式:「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. クライアント初期化パターン

t ui.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 table は、セルごとまたは列ごとのスタイル設定をサポートしていません
  • TableWithStyleFunc は bubbles table をラップし、StyleFunc のサポートを追加します
  • StyleFunc により、行または列のインデックスに基づいてセルのスタイルを動的に設定できます
  • cmd/hatchet-cli/cli/tui/table_custom.go にあります
  • bubbles table の操作性(カーソル、選択、キーボードナビゲーション)を維持します
テーブルのスタイル設定
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/Shift+Tab を使用します
  • キャンセル:戻る/キャンセルには Esc を使用します
  • 更新:データを手動で更新するには r を使用します
  • フィルター:該当する場合、フィルターモーダルを開くには f を使用します
  • デバッグ:デバッグビューの切り替えには d を使用します(「デバッグログ」セクションを参照)
  • クリア:デバッグビューでデバッグログを消去するには c を使用します
  • タブビュー:タブの切り替えには 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 には *int ではなく *int64 を使用します
    • Since パラメーターには *time.Time ではなく time.Time を使用します(生成された型を確認してください)
    • テナント ID とワークフロー 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 ビューの実装とバグ修正に基づく大幅な更新:
    • 参照実装として workers リストビューと worker 詳細ビューを追加
    • 共通 REST API の型一覧に Worker、WorkerRuntimeInfo、Workflow を追加
    • 詳細ビューのヘッダーパターンを文書化(タイトルに特定のリソース名を表示)
    • 複数選択フォームとカスタムキーマップによるフィルタリングのセクションを追加
    • TableWithStyleFunc ラッパーを使用したセルごとのテーブルスタイル設定を文書化
    • 詳細ビューでのステータスバッジのレンダリング例を追加
    • ナビゲーションメッセージ(NavigateToWorkerMsg パターン)を文書化
    • 列の配置に関するベストプラクティスを追加(ヘッダーの書式文字列を行のレンダリングに合わせる)
    • Workflow TUI の実装を更新:
      • ヘッダーコンポーネントのドキュメントを更新:一貫性を保つため、すべてのヘッダー(メイン、詳細、モーダル)でハイライト色を使用
      • メインビュー用に RenderHeaderWithViewIndicator() を追加(繰り返しを避け、ビュー名のみを表示)
      • セクション14を追加:テーブルの高さ計算とレイアウトの最適化(height - 12 対 height - 16)
      • セクション15を追加:関連ビュー間での列の一貫性(UX にとって重要)
      • セクション16を追加:ビューのナビゲーションとモーダルセレクタのパターン
      • Shift+Tab と矢印キーに対応するモーダルセレクタを文書化
      • 詳細ビューを掘り下げるためのナビゲーションスタックパターンを文書化
  • 2026-01-09: 自動更新の手順と検証チェックリストを追加
  • 包括的な TUI ビュー構築ガイドラインを含むドキュメントを作成
既知の問題と解決策
問題:テーブル列の配置の不一致

問題:書式文字列の幅が一致しないため、ヘッダーの列とテーブルの行が揃いません。

例:run 詳細の tasks タブで、ヘッダーは選択列に %-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 システムまたはこのスキルドキュメントの改善候補を記録します)

コメント