build-tui-view について
build-tui-viewは、bubbleteaとlipglossを使ってHatchet CLIのTUIビューを作成・保守するスキルです。ビュー構成や再利用可能なUI部品に加え、フロントエンドを参照して手順を検証する方法を説明します。
主な機能
- 01bubbleteaとlipglossによるビュー作成ガイド
- 02Hatchet CLIのモジュール式ビュー構成
- 03ヘッダー・説明・フッターの共通部品
- 04フロントエンドとAPIの参照手順
- 05自己修正とドキュメント検証
活用シーン
- 01Hatchet CLIの一覧ビュー実装
- 02共通部品を使った詳細ビュー作成
- 03TUIの列やデータをフロントエンドに合わせる
- 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 呼び出しを把握してください。
自己更新スキルの手順
最重要 — 最初に読むこと:このスキルドキュメントは、継続的に改善され、正確さを保つように設計されています。
このスキルを更新するタイミング
次の場合は、このスキルファイルを必ず更新してください。
-
不正確な情報を発見した場合
- ファイルパスやディレクトリ構造が間違っていることに気づいたとき
- コード例がコンパイルできない、または実際の実装と一致しないとき
- API シグネチャが変更されているとき
- 記載されたファイルが指定の場所に存在しないとき
-
新しいパターンを学んだ場合
- 新しいビューを実装する際に、より良い方法を見つけたとき
- ユーザーから新しい規約やパターンを教わったとき
- ドキュメントに記載すべき再利用可能なパターンを見つけたとき
- 注意喚起すべきよくある落とし穴を見つけたとき
-
情報の不足を見つけた場合
- ここに記載されていない情報が必要になったとき
- コードベースに新しいコンポーネントやユーティリティが追加されたとき
- 新しい bubbletea/lipgloss のパターンが採用されたとき
-
ユーザーから修正を受けた場合
- ユーザーがこのドキュメントの情報を修正したとき
- ユーザーから新しい方法や規約が提示されたとき
- ユーザーが古い情報を指摘したとき
このスキルの更新方法
このスキルを更新する際は、次の手順に従ってください。
- 追加前に検証する:このドキュメントに追加する前に、実際のコードベースと照らし合わせてパス、コード、API シグネチャを必ず確認してください
- Read/Glob/Grep を使用する:実際のファイルを確認し、正確さを確かめてください
- コード例をテストする:コード例がコンパイルでき、現在のパターンに沿っていることを確認してください
- 具体的に記述する:正確なファイルパス、関数シグネチャ、動作するコード例を記載してください
- すぐに更新する:不正確な情報が見つかったら、セッションの最後ではなく、その時点で更新してください
- 構造を維持する:既存のドキュメントの構造と書式を維持してください
- 背景を加える:新しいセクションを追加する際は、そのパターンが推奨される理由を説明してください
検証チェックリスト
このスキルの情報を使用する前に、次の点を確認してください。
- ファイルパスが存在し、正しいこと
- コード例が現在の実装と一致していること
- API シグネチャが生成された REST クライアントと一致していること
- 再利用可能なコンポーネントが正しく参照されていること
- ディレクトリ構造が正確であること
自己修正の手順
作業中に不正確な情報を見つけた場合は、次の手順に従ってください。
- 問題をすぐに記録します
- 実際のファイルを読み、正しい情報を確認します
- このスキルドキュメントを修正します
- 修正した情報に基づいて、ユーザーのタスクを続行します
重要:このスキルは、使用するたびにより正確で包括的になる、生きたドキュメントである必要があります。
プロジェクトの概要
- フレームワーク:bubbletea(TUI フレームワーク)
- スタイル設定:lipgloss(スタイル定義)
- TUI コマンドの場所:
cmd/hatchet-cli/cli/tui.go - ビューの場所:
cmd/hatchet-cli/cli/tui/ディレクトリ - テーマ:
cmd/hatchet-cli/cli/internal/styles/styles.goに定義済みの Hatchet テーマ - フロントエンドの参照先:
frontend/app/src/pages/main/v1/ディレクトリ
フロントエンドの先行実装を探す
最初に必ず行うこと:TUI ビューを実装する前に、対応するフロントエンドビューを見つけ、次の点を確認してください。
- 列の構成と名前
- API エンドポイントとクエリパラメーター
- 使用されているデータ型とフィールド
- フィルタリングと並べ替えのロジック
フロントエンドの参照先を探す手順:
-
フロントエンドビューを見つける
# Navigate to frontend pages cd frontend/app/src/pages/main/v1/ # Find views related to your feature (e.g., workflow-runs, tasks, events) ls -la -
列の定義を確認する
{feature}-columns.tsxのようなファイルを探します- 列のキー、タイトル、アクセサーを確認します
- 例:
frontend/app/src/pages/main/v1/workflow-runs-v1/components/v1/task-runs-columns.tsx
export const TaskRunColumn = { taskName: "Task Name", status: "Status", workflow: "Workflow", createdAt: "Created At", startedAt: "Started At", duration: "Duration", }; -
データフックを特定する
hooks/ディレクトリ内のuse-{feature}.tsxファイルを探します- これらのファイルには API クエリのロジックが含まれています
- 例:
frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-runs.tsx
-
API クエリを確認する
frontend/app/src/lib/api/queries.tsでクエリの定義を確認します- エンドポイント名とパラメーターを確認します
- 例:
v1WorkflowRuns: { list: (tenant: string, query: V2ListWorkflowRunsQuery) => ({ queryKey: ['v1:workflow-run:list', tenant, query], queryFn: async () => (await api.v1WorkflowRunList(tenant, query)).data, }), } -
Go REST クライアントに対応付ける
- フロントエンドの
api.v1WorkflowRunList()は、Go のclient.API().V1WorkflowRunListWithResponse()に対応します - フロントエンドのクエリパラメーターは、Go の構造体パラメーターに対応します
- 対応例:
// Frontend api.v1WorkflowRunList(tenantId, { offset: 0, limit: 100, since: createdAfter, only_tasks: true, }) // Go equivalent client.API().V1WorkflowRunListWithResponse( ctx, client.TenantId(), &rest.V1WorkflowRunListParams{ Offset: int64Ptr(0), Limit: int64Ptr(100), Since: &since, OnlyTasks: true, }, ) - フロントエンドの
例:フロントエンドを参考にタスクビューを実装する
-
フロントエンドの構成:
frontend/app/src/pages/main/v1/workflow-runs-v1/- 列:
task-runs-columns.tsx - フック:
use-runs.tsx - テーブル:
runs-table.tsx
- 列:
-
列名を抽出する:
taskName, status, workflow, createdAt, startedAt, duration; -
API 呼び出しを特定する:
queries.v1WorkflowRuns.list(tenantId, { offset, limit, statuses, workflow_ids, since, until, only_tasks: true, }); -
TUI に実装する:
// Create matching columns columns := []table.Column{ {Title: "Task Name", Width: 30}, {Title: "Status", Width: 12}, {Title: "Workflow", Width: 25}, {Title: "Created At", Width: 16}, {Title: "Started At", Width: 16}, {Title: "Duration", Width: 12}, } // Call matching API endpoint response, err := client.API().V1WorkflowRunListWithResponse( ctx, client.TenantId(), &rest.V1WorkflowRunListParams{ Offset: int64Ptr(0), Limit: int64Ptr(100), Since: &since, OnlyTasks: true, }, )
再利用可能なコンポーネント(重要:最初に読むこと)
重要:アプリケーション全体の一貫性を保つため、すべての TUI ビューで view.go に定義された標準の再利用可能なコンポーネントを使用してください。ヘッダーやフッターのスタイルコードをコピーして貼り付けないでください。
ヘッダーコンポーネント
重要:TUI 全体のすべてのヘッダーで、タイトルにマゼンタのハイライトカラー(styles.HighlightColor)を使用し、すべてのビュー(メインビュー、詳細ビュー、モーダルなど)で一貫した視覚的な強調を実現してください。
詳細ビューとモーダルの場合
詳細ビュー、モーダル、セカンダリ画面では、必ず RenderHeader() を使用してください:
header := RenderHeader("Workflow Details", v.Ctx.ProfileName, v.Width)
header := RenderHeader("Task Details", v.Ctx.ProfileName, v.Width)
header := RenderHeader("Filter Tasks", v.Ctx.ProfileName, v.Width)
メインビューの場合
メインビュー/リストビューでは、RenderHeaderWithViewIndicator() を使用してください:
// For primary list views - shows just the view name, no repetitive "Hatchet Workflows [Workflows]"
header := RenderHeaderWithViewIndicator("Runs", v.Ctx.ProfileName, v.Width)
header := RenderHeaderWithViewIndicator("Workflows", v.Ctx.ProfileName, v.Width)
この関数は、ハイライトカラーでビュー名(例:「Runs」や「Workflows」)のみを表示するため、シンプルで重複のない表示になります。
両方のヘッダー関数の機能:
- タイトルをマゼンタのハイライトカラー(
styles.HighlightColor)で表示します。これはすべてのビューで一貫しています - 右側にロゴ(テキスト形式:「HATCHET TUI」)を表示します
- プロファイル名を表示します
- 下端に枠線を表示します
- ターミナルの幅に応じて表示を調整します
❌ 絶対に次のようにしないでください:
// Bad: Copy-pasting header styles
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
BorderStyle(lipgloss.NormalBorder()).
// ... more styling
header := headerStyle.Render(fmt.Sprintf("My View - Profile: %s", profile))
// Bad: Calling RenderHeaderWithLogo directly (bypasses highlight color)
header := RenderHeaderWithLogo(fmt.Sprintf("My View - Profile: %s", profile), v.Width)
✅ 必ず次のようにしてください:
// Good: Use the reusable component for detail views
header := RenderHeader("Task Details", v.Ctx.ProfileName, v.Width)
// Good: Use the view indicator variant for primary views
header := RenderHeaderWithViewIndicator("Runs", v.Ctx.ProfileName, v.Width)
操作説明コンポーネント
RenderInstructions() を使用して、状況に応じたヘルプテキストを表示します:
instructions := RenderInstructions(
"Your instructions here • Use bullets to separate items",
v.Width,
)
機能:
- 視覚的なノイズを抑える控えめな色のスタイル
- 幅の制約に自動で対応します
- 一貫したパディング
- 箇条書きの区切り記号(•)を使用します
フッターコンポーネント
ナビゲーションや操作方法のヒントには、必ず RenderFooter() を使用してください:
footer := RenderFooter([]string{
"↑/↓: Navigate",
"Enter: Select",
"Esc: Cancel",
"q: Quit",
}, v.Width)
機能:
- 上端に枠線を付けた一貫したスタイル
- 操作項目を箇条書き(•)で自動的に連結します
- 目立ちすぎない表示のため、控えめな色を使用します
- ターミナルの幅に応じて表示を調整します
標準的なビュー構成
すべてのビューで、次の一貫した構成に従ってください:
func (v *YourView) View() string {
var b strings.Builder
// 1. Header (always) - USE REUSABLE COMPONENT
header := RenderHeader("View Title", v.Ctx.ProfileName, v.Width)
b.WriteString(header)
b.WriteString("\n\n")
// 2. Instructions (when helpful) - USE REUSABLE COMPONENT
instructions := RenderInstructions("Your instructions", v.Width)
b.WriteString(instructions)
b.WriteString("\n\n")
// 3. Main content
// ... your view-specific content ...
// 4. Footer (always) - USE REUSABLE COMPONENT
footer := RenderFooter([]string{
"control1: Action1",
"control2: Action2",
}, v.Width)
b.WriteString(footer)
return b.String()
}
アーキテクチャ
ルート TUI モデル(tui.go)
ルート TUI コマンドは、次の役割を担います。
- プロファイルの選択と検証
- Hatchet クライアントの初期化
- ビューコンテキストの作成
- 現在のビューの管理
- ビューへの更新の委譲
ビューシステム(views/ ディレクトリ)
各ビューは、View インターフェースを実装する個別のファイルです。
view.go- 基本のビューインターフェース、コンテキスト、および再利用可能なコンポーネント{viewname}.go- 個々のビューの実装(例:tasks.go)
基本原則
1. ファイル構成
TUI コマンドファイル
- ファイル:
cmd/hatchet-cli/cli/tui.go - 目的:コマンドの設定、プロファイルの選択、クライアントの初期化、ビューの管理
ビューファイル
- 場所:
cmd/hatchet-cli/cli/tui/ - ファイル:
view.go- ビューインターフェースと基本型{viewname}.go- 個々のビューの実装
2. ビューインターフェース
すべてのビューは、このインターフェース(views/view.go で定義)を実装する必要があります。
package views
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/hatchet-dev/hatchet/pkg/client"
)
// ViewContext contains the shared context passed to all views
type ViewContext struct {
// Profile name for display
ProfileName string
// Hatchet client for API calls
Client client.Client
// Terminal dimensions
Width int
Height int
}
// View represents a TUI view component
type View interface {
// Init initializes the view and returns any initial commands
Init() tea.Cmd
// Update handles messages and updates the view state
Update(msg tea.Msg) (View, tea.Cmd)
// View renders the view to a string
View() string
// SetSize updates the view dimensions
SetSize(width, height int)
}
3. ベースモデルパターン
共通のビュー項目には BaseModel を使用します。
// BaseModel contains common fields for all views
type BaseModel struct {
Ctx ViewContext
Width int
Height int
Err error
}
// Your view embeds BaseModel
type YourView struct {
BaseModel
// Your view-specific fields
table table.Model
items []YourDataType
}
4. 新しいビューの作成
ステップ 1:ビューファイルを作成する
cmd/hatchet-cli/cli/tui/{viewname}.go を作成します。
package views
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles"
"github.com/hatchet-dev/hatchet/pkg/client/rest"
)
type YourView struct {
BaseModel
// View-specific fields
}
// NewYourView creates a new instance of your view
func NewYourView(ctx ViewContext) *YourView {
v := &YourView{
BaseModel: BaseModel{
Ctx: ctx,
},
}
// Initialize view components
return v
}
func (v *YourView) Init() tea.Cmd {
return nil
}
func (v *YourView) Update(msg tea.Msg) (View, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
return v, nil
case tea.KeyMsg:
switch msg.String() {
case "r":
// Refresh logic
return v, nil
}
}
// Update sub-components
return v, cmd
}
func (v *YourView) View() string {
if v.Width == 0 {
return "Initializing..."
}
// Build your view
return "Your view content"
}
func (v *YourView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
// Update view-specific components
}
ステップ 2:TUI でビューを使用する
ルート TUI モデルがビューを管理します。
// In tui.go
func newTUIModel(profileName string, hatchetClient client.Client) tuiModel {
ctx := views.ViewContext{
ProfileName: profileName,
Client: hatchetClient,
}
// Initialize with your view
currentView := views.NewYourView(ctx)
return tuiModel{
currentView: currentView,
}
}
5. クライアント初期化パターン
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 を使用してください。
新しいステータスカラーの追加
新しいステータスカラーを追加する必要がある場合:
- 色定数を
cmd/hatchet-cli/cli/internal/styles/styles.goに追加します cmd/hatchet-cli/cli/internal/styles/status.goでユーティリティ関数を作成または更新します- 色の値については、フロントエンドのバッジバリアント
frontend/app/src/components/v1/ui/badge.tsxを参照します - ライト/ダークターミナルをサポートするため、アダプティブカラーを使用します
8. 標準のキーボード操作
予測しやすいユーザー体験を提供するため、すべてのビューで一貫したキー割り当てを使用します。
グローバル操作(tui.go で処理)
qまたはctrl+c:TUI を終了します
ビュー固有の操作
個々のビューで次の操作を実装します。
- ナビゲーション:リストの移動には
↑/↓または矢印キーを使用します - 選択:選択/確定には
Enterを使用します - タブ移動:フォームフィールドの移動には
Tab/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.V1TaskSummaryrest.V1TaskSummaryListrest.V1WorkflowRunrest.V1WorkflowRunDetailsrest.Workerrest.WorkerRuntimeInforest.Workflowrest.APIResourceMeta
非同期データ取得パターン
// Define custom message types in your view file
type yourDataMsg struct {
items []YourDataType
err error
}
// Create fetch command
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
// Use v.Ctx.Client to make API calls
// Return yourDataMsg
}
}
// Handle in Update
case yourDataMsg:
v.loading = false
if msg.err != nil {
v.HandleError(msg.err)
} else {
v.items = msg.items
v.ClearError()
}
11. モーダルビュー
フィルターフォームや確認ダイアログのようなモーダルオーバーレイを作成する場合:
RenderHeader()を使用して、更新したタイトルとともにヘッダーを表示しますRenderInstructions()を使用して、モーダル操作に固有の説明を表示します- モーダルの内容を表示します
RenderFooter()を使用して、モーダル固有の操作を含むフッターを表示します
モーダル構成の例:
func (v *TasksView) renderFilterModal() string {
var b strings.Builder
// 1. Header - USE REUSABLE COMPONENT
header := RenderHeader("Filter Tasks", v.Ctx.ProfileName, v.Width)
b.WriteString(header)
b.WriteString("\n\n")
// 2. Instructions - USE REUSABLE COMPONENT
instructions := RenderInstructions("Configure filters and press Enter to apply", v.Width)
b.WriteString(instructions)
b.WriteString("\n\n")
// 3. Modal content (form, etc.)
b.WriteString(v.filterForm.View())
b.WriteString("\n")
// 4. Footer - USE REUSABLE COMPONENT
footer := RenderFooter([]string{"Enter: Apply", "Esc: Cancel"}, v.Width)
b.WriteString(footer)
return b.String()
}
重要:一貫性を保つため、モーダルも通常のビューと同じ視覚的構成(ヘッダー、説明、コンテンツ、フッター)にしてください。
12. フォームとの統合
ビューで huh フォームを使用する場合:
- Hatchet テーマを設定する:
.WithTheme(styles.HatchetTheme()) - フォームをメインの tea.Program に直接統合する(別のプログラムを実行しないでください)
form.State == huh.StateCompletedを確認してフォームの完了を処理する- フォームがアクティブな間は、キーイベントだけでなく、すべてのメッセージをフォームに渡す
例:
import "github.com/charmbracelet/huh"
// In Update()
if v.showingFilter && v.filterForm != nil {
// Pass ALL messages to form when active
form, cmd := v.filterForm.Update(msg)
v.filterForm = form.(*huh.Form)
// Check if form completed
if v.filterForm.State == huh.StateCompleted {
v.showingFilter = false
// Process form values
}
return v, cmd
}
13. テーブルコンポーネント
github.com/charmbracelet/bubbles/table の使用方法:
import "github.com/charmbracelet/bubbles/table"
// Define columns
columns := []table.Column{
{Title: "Column1", Width: 20},
{Title: "Column2", Width: 30},
}
// Create table
t := table.New(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(20),
)
// Apply Hatchet styles
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(styles.AccentColor).
BorderBottom(true).
Bold(true).
Foreground(styles.AccentColor)
s.Selected = s.Selected.
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true)
t.SetStyles(s)
// Update rows
rows := make([]table.Row, len(items))
for i, item := range items {
rows[i] = table.Row{item.Field1, item.Field2}
}
t.SetRows(rows)
14. テーブルの高さの計算とレイアウトの最適化
非常に重要:ターミナルのスペースを最適に利用するには、テーブルの高さを適切に計算することが不可欠です。表示される UI 要素に応じて、ビューの種類ごとに異なる計算が必要です。
ビューの種類ごとの標準的な高さの計算
主要なリストビュー(例:runs_list、workflows):
- 計算式:
height - 12 - 含まれる要素:ヘッダー(3 行)、統計バー(2 行)、間隔(2 行)、フッター(2 行)、バッファー(3 行)
func (v *RunsListView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
v.table.SetHeight(msg.Height - 12) // Primary view calculation
return v, nil
}
// ...
}
func (v *RunsListView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
if height > 12 {
v.table.SetHeight(height - 12)
}
}
追加情報セクションのある詳細ビュー(例:ワークフロー情報と実行テーブルを含む workflow_details):
- 計算式:
height - 16(または情報セクションのサイズに応じて調整) - 含まれる要素:ヘッダー(3 行)、情報セクション(4 行)、セクションヘッダー(2 行)、間隔(2 行)、フッター(2 行)、バッファー(3 行)
func (v *WorkflowDetailsView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
v.table.SetHeight(msg.Height - 16) // Detail view with extra info
return v, nil
}
// ...
}
func (v *WorkflowDetailsView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
if height > 16 {
v.table.SetHeight(height - 16)
}
}
高さの計算に関するガイドライン
- UI 要素を数える:テーブルの上下に表示されるすべての要素をリストアップします
- 行数を見積もる:
- ヘッダー:約 3 行(間隔を含む)
- 統計バー:約 2 行(間隔を含む)
- セクションヘッダー:それぞれ約 2 行
- 情報セクション:内容に応じて約 3~5 行
- フッター:約 2 行(間隔を含む)
- バッファー:安全のため約 2~3 行
- さまざまなサイズでテストする:最小ターミナルサイズ(80x24)でテーブルに十分なスペースがあることを確認します
- 必要に応じて調整する:テーブルが窮屈に感じる場合は、高さのオフセットを 2~4 行減らします
よくある間違い:追加の UI 要素を考慮せず、すべてのビューで同じ高さの計算を使用することです。
❌ 誤り:
// Detail view with extra info section but using primary view calculation
v.table.SetHeight(msg.Height - 12) // Table will be too large, overlapping footer
✅ 正しい例:
// Adjust calculation based on actual UI elements in the view
v.table.SetHeight(msg.Height - 16) // Accounts for extra info section
15. 関連するビュー間での列の一貫性
非常に重要:詳細ビューに、主要なリストビューと概念的に類似するリスト(例:ワークフローの詳細に、メインの実行リストと同じ最近の実行を表示する場合)があるときは、一貫性を保ち、ユーザーの期待に応えるため、列を必ず完全に一致させてください。
列の一貫性が重要な理由
- ユーザーエクスペリエンス:ユーザーは、ビューが異なっても同じ情報が同じ形式で表示されることを期待します
- 認知負荷:列の一貫性により、コンテキストを切り替える際の認知的負担が軽減されます
- 視覚的な親しみやすさ:同じ列構成にすることで、ビュー間の関連性が強まります
例:実行リストの列
主要ビュー(runs_list.go):
columns := []table.Column{
{Title: "Task Name", Width: 30},
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25},
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12},
}
詳細ビュー(workflow_details.go でワークフローの最近の実行を表示):
// MUST use the same columns as runs_list.go
columns := []table.Column{
{Title: "Task Name", Width: 30},
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25}, // Keep this even if redundant
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12},
}
列の一貫性を実装する
関連するリストを含む詳細ビューを実装する場合:
- 主要ビューを参照する:主要なリストビューで使用されている列を確認します
- 列構成を完全にコピーする:タイトル、順序、幅をすべて一致させます
- すべての列を保持する:詳細ビューでは冗長に見えても、列を削除しないでください
- 行の設定を更新する:
updateTableRows()がすべての列に正しく値を設定するようにします
❌ 誤り:
// Workflow details view using different columns than runs list
columns := []table.Column{
{Title: "Name", Width: 40}, // Different title
{Title: "Created At", Width: 16},
{Title: "Status", Width: 12}, // Different order
// Missing: Workflow, Started At, Duration
}
✅ 正しい例:
// Workflow details view matching runs list exactly
columns := []table.Column{
{Title: "Task Name", Width: 30}, // Same titles
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25}, // Same order
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12}, // All columns included
}
16. ビューのナビゲーションとモーダルセレクター
TUI では、詳細を順に表示するためのナビゲーションスタックシステムと、メインビューを切り替えるためのモーダルセレクターを使用します。
ナビゲーションスタックパターン
ルート TUI モデルは、戻るナビゲーション用に viewStack を保持します。
type tuiModel struct {
currentView tui.View
viewStack []tui.View // Stack for back navigation
// ...
}
詳細ビューへの移動:
case tui.NavigateToWorkflowMsg:
// Push current view onto stack
m.viewStack = append(m.viewStack, m.currentView)
// Create and initialize detail view
detailView := tui.NewWorkflowDetailsView(m.ctx, msg.WorkflowID)
detailView.SetSize(m.width, m.height)
m.currentView = detailView
return m, detailView.Init()
戻る操作:
case tui.NavigateBackMsg:
// Pop view from stack
if len(m.viewStack) > 0 {
m.currentView = m.viewStack[len(m.viewStack)-1]
m.viewStack = m.viewStack[:len(m.viewStack)-1]
m.currentView.SetSize(m.width, m.height)
}
return m, nil
詳細ビューの場合(戻るナビゲーション用に Esc キーを処理します):
case tea.KeyMsg:
switch msg.String() {
case "esc":
// Navigate back to previous view
return v, NewNavigateBackMsg()
}
モーダルビューフィルターパターン
モーダルセレクターを使用すると、Shift+Tab でメインビューを切り替えられます。
モーダルを開く:
case tea.KeyMsg:
switch msg.String() {
case "shift+tab":
// Find current view type in the list
for i, opt := range availableViews {
if opt.Type == m.currentViewType {
m.selectedViewIndex = i
break
}
}
m.showViewSelector = true
return m, nil
}
モーダル内のナビゲーション(Tab、矢印キー、vim キーに対応):
if m.showViewSelector {
switch msg.String() {
case "shift+tab", "tab", "down", "j":
// Cycle forward
m.selectedViewIndex = (m.selectedViewIndex + 1) % len(availableViews)
return m, nil
case "up", "k":
// Cycle backward
m.selectedViewIndex = (m.selectedViewIndex - 1 + len(availableViews)) % len(availableViews)
return m, nil
case "enter":
// Confirm selection and switch view
selectedType := availableViews[m.selectedViewIndex].Type
if selectedType != m.currentViewType {
// Only switch if in a primary view
if m.isInPrimaryView() {
m.currentViewType = selectedType
m.currentView = m.createViewForType(selectedType)
m.currentView.SetSize(m.width, m.height)
m.showViewSelector = false
return m, m.currentView.Init()
}
}
m.showViewSelector = false
return m, nil
case "esc":
// Cancel without switching
m.showViewSelector = false
return m, nil
}
return m, nil
}
モーダルの描画:
func (m tuiModel) renderViewSelector() string {
var b strings.Builder
// Use reusable header component
header := tui.RenderHeader("Select View", m.ctx.ProfileName, m.width)
b.WriteString(header)
b.WriteString("\n\n")
// Instructions
instructions := tui.RenderInstructions(
"↑/↓ or Tab: Navigate • Enter: Confirm • Esc: Cancel",
m.width,
)
b.WriteString(instructions)
b.WriteString("\n\n")
// View options with highlighting
for i, opt := range availableViews {
if i == m.selectedViewIndex {
// Highlighted option
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true).
Padding(0, 2)
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s - %s", opt.Name, opt.Description)))
} else {
// Non-highlighted option
normalStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
Padding(0, 2)
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s - %s", opt.Name, opt.Description)))
}
b.WriteString("\n")
}
// Footer
footer := tui.RenderFooter([]string{
"Tab: Cycle",
"Enter: Confirm",
"Esc: Cancel",
}, m.width)
b.WriteString("\n")
b.WriteString(footer)
return b.String()
}
主な原則:
- ナビゲーションスタック:階層的なナビゲーション(リスト → 詳細 → 戻る)に使用します
- モーダルセレクター:最上位のビューの切り替えに使用します
- メインビューの確認:詳細ビューでない場合にのみ、ビューの切り替えを許可します
- 一貫したキー割り当て:
Shift+Tab:ビュ―セレクターを開くEsc:戻る(詳細ビューの場合)またはキャンセル(モーダルの場合)Enter:項目を選択するか、操作を確定する- 矢印キー/vim キー:リストやモーダル内を移動する
よく使われるパターン
書式設定ユーティリティ
期間の書式設定
func formatDuration(ms int) string {
duration := time.Duration(ms) * time.Millisecond
if duration < time.Second {
return fmt.Sprintf("%dms", ms)
}
seconds := duration.Seconds()
if seconds < 60 {
return fmt.Sprintf("%.1fs", seconds)
}
minutes := int(seconds / 60)
secs := int(seconds) % 60
return fmt.Sprintf("%dm%ds", minutes, secs)
}
ID の省略表示
func truncateID(id string, length int) string {
if len(id) > length {
return id[:length]
}
return id
}
ステータスの描画
重要:ステータスを手動でスタイル設定しないでください。ステータスユーティリティ関数を使用してください。
// For V1TaskStatus (from REST API)
status := styles.RenderV1TaskStatus(task.Status)
// The utility automatically handles:
// - COMPLETED -> Green "Succeeded"
// - FAILED -> Red "Failed"
// - CANCELLED -> Orange "Cancelled"
// - RUNNING -> Yellow "Running"
// - QUEUED -> Gray "Queued"
// All colors match frontend badge variants
自動更新パターン
// Define tick message in your view file
type tickMsg time.Time
// Create tick command
func tick() tea.Cmd {
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// Handle in Update
case tickMsg:
// Refresh data
return v, tea.Batch(v.fetchData(), tick())
デバッグログパターン
重要:API 呼び出しを行うビューや、複雑な状態管理を行うビューでは、メモリリークを防ぐため、リングバッファを使用したデバッグログシステムを実装してください。
ステップ 1:デバッグロガーを作成する(まだ存在しない場合)
cmd/hatchet-cli/cli/tui/debug.go を作成します。
package views
import (
"fmt"
"sync"
"time"
)
// DebugLog represents a single debug log entry
type DebugLog struct {
Timestamp time.Time
Message string
}
// DebugLogger is a fixed-size ring buffer for debug logs
type DebugLogger struct {
mu sync.RWMutex
logs []DebugLog
capacity int
index int
size int
}
// NewDebugLogger creates a new debug logger with the specified capacity
func NewDebugLogger(capacity int) *DebugLogger {
return &DebugLogger{
logs: make([]DebugLog, capacity),
capacity: capacity,
index: 0,
size: 0,
}
}
// Log adds a new log entry to the ring buffer
func (d *DebugLogger) Log(format string, args ...interface{}) {
d.mu.Lock()
defer d.mu.Unlock()
d.logs[d.index] = DebugLog{
Timestamp: time.Now(),
Message: fmt.Sprintf(format, args...),
}
d.index = (d.index + 1) % d.capacity
if d.size < d.capacity {
d.size++
}
}
// GetLogs returns all logs in chronological order
func (d *DebugLogger) GetLogs() []DebugLog {
d.mu.RLock()
defer d.mu.RUnlock()
if d.size == 0 {
return []DebugLog{}
}
result := make([]DebugLog, d.size)
if d.size < d.capacity {
// Buffer not full yet, logs are from 0 to index-1
copy(result, d.logs[:d.size])
} else {
// Buffer is full, logs wrap around
// Copy from index to end (older logs)
n := copy(result, d.logs[d.index:])
// Copy from start to index (newer logs)
copy(result[n:], d.logs[:d.index])
}
return result
}
// Clear removes all logs
func (d *DebugLogger) Clear() {
d.mu.Lock()
defer d.mu.Unlock()
d.index = 0
d.size = 0
}
// Size returns the current number of logs
func (d *DebugLogger) Size() int {
d.mu.RLock()
defer d.mu.RUnlock()
return d.size
}
// Capacity returns the maximum capacity
func (d *DebugLogger) Capacity() int {
return d.capacity
}
ステップ 2:ビューにデバッグロガーを統合する
type YourView struct {
BaseModel
// ... other fields
debugLogger *DebugLogger
showDebug bool // Whether to show debug overlay
}
func NewYourView(ctx ViewContext) *YourView {
v := &YourView{
BaseModel: BaseModel{
Ctx: ctx,
},
debugLogger: NewDebugLogger(5000), // 5000 log entries max
showDebug: false,
}
v.debugLogger.Log("YourView initialized")
return v
}
ステップ 3:ビュー全体にデバッグログを追加する
// Log important events
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
v.debugLogger.Log("Fetching data...")
// Make API call
response, err := v.Ctx.Client.API().SomeEndpoint(...)
if err != nil {
v.debugLogger.Log("Error fetching data: %v", err)
return dataMsg{err: err}
}
v.debugLogger.Log("Successfully fetched %d items", len(response.Items))
return dataMsg{data: response.Items}
}
}
ステップ 4:切り替えキーのハンドラーを追加する
func (v *YourView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "d":
// Toggle debug view
v.showDebug = !v.showDebug
v.debugLogger.Log("Debug view toggled: %v", v.showDebug)
return v, nil
case "c":
// Clear debug logs (only when in debug view)
if v.showDebug {
v.debugLogger.Clear()
v.debugLogger.Log("Debug logs cleared")
}
return v, nil
}
}
// ... rest of update logic
}
ステップ 5:デバッグビューの描画を実装する
func (v *YourView) View() string {
if v.Width == 0 {
return "Initializing..."
}
// If debug view is enabled, show debug overlay
if v.showDebug {
return v.renderDebugView()
}
// ... normal view rendering
}
func (v *YourView) renderDebugView() string {
logs := v.debugLogger.GetLogs()
// Header
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
BorderForeground(styles.AccentColor).
Width(v.Width-4).
Padding(0, 1)
header := headerStyle.Render(fmt.Sprintf(
"Debug Logs - %d/%d entries",
v.debugLogger.Size(),
v.debugLogger.Capacity(),
))
// Log entries
logStyle := lipgloss.NewStyle().
Padding(0, 1).
Width(v.Width - 4)
var b strings.Builder
b.WriteString(header)
b.WriteString("\n\n")
// Calculate how many logs we can show
maxLines := v.Height - 8 // Reserve space for header, footer, controls
if maxLines < 1 {
maxLines = 1
}
// Show most recent logs first
startIdx := 0
if len(logs) > maxLines {
startIdx = len(logs) - maxLines
}
for i := startIdx; i < len(logs); i++ {
log := logs[i]
timestamp := log.Timestamp.Format("15:04:05.000")
logLine := fmt.Sprintf("[%s] %s", timestamp, log.Message)
b.WriteString(logStyle.Render(logLine))
b.WriteString("\n")
}
// Footer with controls
footerStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
BorderStyle(lipgloss.NormalBorder()).
BorderTop(true).
BorderForeground(styles.AccentColor).
Width(v.Width-4).
Padding(0, 1)
controls := footerStyle.Render("d: Close Debug | c: Clear Logs | q: Quit")
b.WriteString("\n")
b.WriteString(controls)
return b.String()
}
ステップ 6:フッターのコントロールを更新する
通常のビューのフッターにデバッグコントロールを追加します。
controls := footerStyle.Render("↑/↓: Navigate | r: Refresh | d: Debug | q: Quit")
利点:
- 固定サイズのリングバッファにより、メモリリークを防止します
- ミューテックスによる保護でスレッドセーフです
- TUI を再起動せずにオン/オフを切り替えられます
- API の問題や状態の変化の診断に役立ちます
- ログを表示していないときは、パフォーマンスに影響しません
テスト方法
ダミーデータの生成
開発中は、ビューのファイル内にダミーデータジェネレーターを作成します。
func generateDummyData() []YourDataType {
now := time.Now()
return []YourDataType{
{
Field1: "value1",
Field2: "value2",
CreatedAt: now.Add(-5 * time.Minute),
},
// ... more dummy items
}
}
参考例
ファイル構成の例
cmd/hatchet-cli/cli/
├── tui.go # Root TUI command
└── views/
├── view.go # View interface and base types
├── tasks.go # Tasks view implementation
└── workflows.go # Workflows view implementation (future)
ビューの完全な例
完全な実装については、cmd/hatchet-cli/cli/tui/tasks.go を参照してください。
コンパイルとテスト
最重要:作業を完了とする前に、必ず CLI バイナリがコンパイルできることを確認してください。
コンパイルの確認
ビューを実装または変更した後は、次の手順を実行します。
# Build the CLI binary
go build -o /tmp/hatchet-test ./cmd/hatchet-cli
# Check for errors
echo $? # Should be 0 for success
よくあるコンパイルエラー
-
UUID 型の不一致
// ❌ Wrong - string to UUID client.API().SomeMethod(ctx, client.TenantId(), ...) // ✅ Correct - parse and convert tenantUUID, err := uuid.Parse(client.TenantId()) if err != nil { return msg{err: fmt.Errorf("invalid tenant ID: %w", err)} } client.API().SomeMethod(ctx, openapi_types.UUID(tenantUUID), ...) -
必要なインポート
import ( "github.com/google/uuid" openapi_types "github.com/oapi-codegen/runtime/types" ) -
API パラメーターの型変換
- offset/limit には
*intではなく*int64を使用します - Since パラメーターには
*time.Timeではなくtime.Timeを使用します(生成された型を確認してください) - テナント ID とワークフロー ID には
openapi_types.UUIDを使用します - 正確なパラメーター型は
pkg/client/rest/gen.goで確認してください
- offset/limit には
-
ポインターヘルパー関数
func int64Ptr(i int64) *int64 { return &i }
テストワークフロー
-
コンパイルテスト
go build -o /tmp/hatchet-test ./cmd/hatchet-cli -
Lint テスト
ビルドが成功したら、Lint チェックを実行します。
task pre-commit-runこのコマンドが成功するまで実行を続けます。次に進む前に、報告された Lint の問題を修正してください。
-
基本機能テスト
# Test with profile selection /tmp/hatchet-test tui # Test with specific profile /tmp/hatchet-test tui --profile your-profile -
エラー処理テスト
- プロファイルを設定せずに試します
- 無効なプロファイルで試します
- キーボード操作(q、r、矢印キー)をテストします
-
表示/レイアウトのテスト
新しいビューを実装する場合:
- さまざまなターミナルサイズ(最小 80x24)でテストします
- ヘッダーとフッターが常に表示されることを確認します
- 説明が明確で役立つことを確認します
- ナビゲーション操作がほかのビューと一貫していることを確認します
- ライトとダークの両方のターミナル背景でテストします
- 再利用可能なすべてのコンポーネントが正しく描画されることを確認します
新しい TUI ビューのチェックリスト
作業を始める前に
-
frontend/app/src/pages/main/v1/で対応するフロントエンドのビューを見つけます - フロントエンドから列の構造と API 呼び出しを特定します
- 使用されている API エンドポイントとパラメーターを正確に確認します
ビューの作成
-
cmd/hatchet-cli/cli/tui/{viewname}.goに新しいファイルを作成します - 必要なインポートを追加します(必要に応じて
uuidとopenapi_typesも含めます) -
BaseModelを埋め込むビュー構造体を定義します -
NewYourView(ctx ViewContext)コンストラクターを作成します -
Init()メソッドを実装します -
Update(msg tea.Msg)メソッドを実装します - 標準の構造に沿って
View()メソッドを実装します -
SetSize(width, height int)メソッドを実装します - 再利用可能なコンポーネントを使用します:
RenderHeader()、RenderInstructions()、RenderFooter() - ヘッダー/フッターのスタイルコードをコピー&ペーストしないでください
- カスタムコンポーネントにのみ Hatchet のテーマカラーとスタイルを適用します
- ビュー固有のキーボード操作を実装します
-
RenderFooter()を使って、すべてのキーボード操作をフッターに記載します - 適切な REST API の型を使用します
-
BaseModel.HandleError()を使ってエラー処理を追加します - レスポンシブレイアウトを追加します(WindowSizeMsg に対応します)
API の統合
- 必要に応じてテナント ID を UUID に変換します
- 正しいパラメーター型(
*int64、time.Timeなど)を使用します - API レスポンスのエラーを処理します
- テーブル表示用にデータを整形します
- 読み込み状態とエラー表示を追加します
統合とテスト
- ビューを
tui.goにインポートします -
newTUIModel()を更新してビューをインスタンス化します - CLI バイナリをコンパイルします(
go build ./cmd/hatchet-cli) - コンパイルエラーを修正します
- 実際のプロファイルを使って基本機能をテストします
- エラーケース(プロファイルなし、無効なプロファイル)をテストします
- キーボード操作(q、r、矢印キー)をテストします
- TUI コマンドのドキュメントを更新します
ベストプラクティス
- 重要:再利用可能なコンポーネント(
RenderHeader、RenderInstructions、RenderFooter)を使用します - 絶対にヘッダー/フッターのスタイルコードをコピー&ペーストしないでください。これは DRY 原則に反します
- ビューのロジックをビューのファイル内に分離します
-
ViewContextを使ってクライアントとプロファイルの情報にアクセスします - すべてのメッセージを適切に処理します(未処理の場合は
v, nilを返します) - 描画する前に、必ず
v.Width == 0を確認します - ほかのビューと一貫性のあるスタイルを使用します(Hatchet のテーマカラーを使用します)
-
RenderFooter()を使って、すべてのキーボード操作をフッターに記載します - 標準のビュー構造(ヘッダー、説明、コンテンツ、フッター)に従います
- 提出する前に、必ずコンパイルを確認します
実装後
- 発見した新しいパターンや知見を反映して、このスキルドキュメントを更新します
- 発生した問題とその解決策を記録します
- 新しいユーティリティ関数やパターンを適切なセクションに追加します
- すべてのコード例とファイルパスが正確であることを確認します
学んだことと更新内容
このセクションには、正確さを保つための最近の知見と更新内容を記録します。
最近の更新
- 2026-01-10: workers ビューの実装とバグ修正に基づく大幅な更新:
- 参照実装として 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 システムまたはこのスキルドキュメントの改善候補を記録します)