with one click
build-tui-view
// Provides instructions for building Hatchet TUI views in the Hatchet CLI.
// Provides instructions for building Hatchet TUI views in the Hatchet CLI.
| name | build-tui-view |
| description | Provides instructions for building Hatchet TUI views in the Hatchet CLI. |
| version | 1 |
| last_updated | "2026-01-09T00:00:00.000Z" |
| self_updating | true |
📝 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.
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.
CRITICAL - READ FIRST: This skill document is designed to be continuously improved and kept accurate.
You MUST update this skill file in the following situations:
Discovering Inaccuracies
Learning New Patterns
Finding Missing Information
User Corrections
When updating this skill:
Before using information from this skill, verify:
If you discover an inaccuracy while working:
Remember: This skill should be a living document that grows more accurate and comprehensive with each use.
cmd/hatchet-cli/cli/tui.gocmd/hatchet-cli/cli/tui/ directorycmd/hatchet-cli/cli/internal/styles/styles.gofrontend/app/src/pages/main/v1/ directoryCRITICAL FIRST STEP: Before implementing any TUI view, locate the corresponding frontend view to understand:
Locate the Frontend View
# 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
Study the Column Definitions
{feature}-columns.tsxfrontend/app/src/pages/main/v1/workflow-runs-v1/components/v1/task-runs-columns.tsxexport const TaskRunColumn = {
taskName: "Task Name",
status: "Status",
workflow: "Workflow",
createdAt: "Created At",
startedAt: "Started At",
duration: "Duration",
};
Identify the Data Hook
use-{feature}.tsx files in the hooks/ directoryfrontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-runs.tsxFind the API Query
frontend/app/src/lib/api/queries.ts for the query definitionv1WorkflowRuns: {
list: (tenant: string, query: V2ListWorkflowRunsQuery) => ({
queryKey: ['v1:workflow-run:list', tenant, query],
queryFn: async () => (await api.v1WorkflowRunList(tenant, query)).data,
}),
}
Map to Go REST Client
api.v1WorkflowRunList() maps to Go's client.API().V1WorkflowRunListWithResponse()// 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 Structure: frontend/app/src/pages/main/v1/workflow-runs-v1/
task-runs-columns.tsxuse-runs.tsxruns-table.tsxExtract Column Names:
taskName, status, workflow, createdAt, startedAt, duration;
Identify API Call:
queries.v1WorkflowRuns.list(tenantId, {
offset,
limit,
statuses,
workflow_ids,
since,
until,
only_tasks: true,
});
Implement in 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,
},
)
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.
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.).
Always use RenderHeader() for detail views, modals, and secondary screens:
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)
Use RenderHeaderWithViewIndicator() for primary/list views:
// 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:
styles.HighlightColor) - consistent across ALL views❌ NEVER do this:
// 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:
// 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)
Use RenderInstructions() to display contextual help text:
instructions := RenderInstructions(
"Your instructions here • Use bullets to separate items",
v.Width,
)
Features:
Always use RenderFooter() for navigation/control hints:
footer := RenderFooter([]string{
"↑/↓: Navigate",
"Enter: Select",
"Esc: Cancel",
"q: Quit",
}, v.Width)
Features:
Every view should follow this consistent structure:
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.go)The root TUI command is responsible for:
views/ directory)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)cmd/hatchet-cli/cli/tui.gocmd/hatchet-cli/cli/tui/view.go - View interface and base types{viewname}.go - Individual view implementationsAll views must implement this interface (defined in 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)
}
Use BaseModel for common view fields:
// 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
}
Create 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
}
The root TUI model manages views:
// 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,
}
}
Always initialize the Hatchet client in tui.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)
}
The Hatchet client is available through the view context:
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,
}
}
}
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.
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
Per-Cell Coloring in Tables: Use the custom TableWithStyleFunc wrapper to enable per-cell styling.
For status rendering in tables:
// 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):
// Render V1TaskStatus with proper colors
status := styles.RenderV1TaskStatus(task.Status)
// Render error messages
errorMsg := styles.RenderError(fmt.Sprintf("Error: %v", err))
Why custom TableWithStyleFunc?
TableWithStyleFunc wraps bubbles table and adds StyleFunc supportcmd/hatchet-cli/cli/tui/table_custom.gos := 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.
If you need to add new status colors:
cmd/hatchet-cli/cli/internal/styles/styles.gocmd/hatchet-cli/cli/internal/styles/status.gofrontend/app/src/components/v1/ui/badge.tsx for color valuesUse consistent key mappings across all views to provide a predictable user experience.
q or ctrl+c: Quit the TUIImplement these in individual views:
↑/↓ or arrow keys for list navigationEnter to select/confirmTab/Shift+Tab for form fieldsEsc to go back/cancelr to manually refresh dataf to open filter modal (where applicable)d to toggle debug view (see Debug Logging section)c to clear debug logs (when in debug view)1, 2, 3, etc. or tab/shift+tab for switching tabsImportant: Always document keyboard controls in the footer using RenderFooter()
CRITICAL: Use the reusable components from view.go for headers, instructions, and footers. See "Reusable Components" section above.
✅ Use the reusable component:
header := RenderHeader("View Title", v.Ctx.ProfileName, v.Width)
❌ DO NOT manually create headers:
// Bad: Don't do this
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
// ... (this violates DRY principle)
✅ Use the reusable component:
footer := RenderFooter([]string{
"↑/↓: Navigate",
"r: Refresh",
"q: Quit",
}, v.Width)
❌ DO NOT manually create footers:
// Bad: Don't do this
footerStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
// ... (this violates DRY principle)
✅ Use the reusable component:
instructions := RenderInstructions("Your helpful instructions here", v.Width)
Custom stats bars are fine for view-specific metrics:
statsStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
Padding(0, 1)
stats := statsStyle.Render(fmt.Sprintf(
"Total: %d | Status1: %d | Status2: %d",
total, status1Count, status2Count,
))
Use generated REST types from:
import "github.com/hatchet-dev/hatchet/pkg/client/rest"
Common types:
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()
}
When creating modal overlays (like filter forms or confirmation dialogs):
RenderHeader()RenderInstructions()RenderFooter()Example Modal Structure:
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.
When using huh forms in views:
.WithTheme(styles.HatchetTheme())form.State == huh.StateCompletedExample:
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
}
Using 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)
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.
Primary List Views (e.g., runs_list, workflows):
height - 12func (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):
height - 16 (or adjust based on info section size)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)
}
}
Common Mistake: Using the same height calculation for all views without accounting for additional UI elements.
❌ Wrong:
// 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:
// Adjust calculation based on actual UI elements in the view
v.table.SetHeight(msg.Height - 16) // Accounts for extra info section
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.
Primary View (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},
}
Detail View (workflow_details.go showing recent runs for a workflow):
// 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},
}
When implementing a detail view with a related list:
updateTableRows() populates all columns correctly❌ Wrong:
// 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:
// 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
}
The TUI uses a navigation stack system for drilling down into details and a modal selector for switching between primary views.
The root TUI model maintains a viewStack for back navigation:
type tuiModel struct {
currentView tui.View
viewStack []tui.View // Stack for back navigation
// ...
}
Navigating to a Detail View:
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:
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):
case tea.KeyMsg:
switch msg.String() {
case "esc":
// Navigate back to previous view
return v, NewNavigateBackMsg()
}
The modal selector allows switching between primary views using Shift+Tab:
Opening the Modal:
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):
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:
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:
Shift+Tab: Open view selectorEsc: Go back (in detail views) or cancel (in modals)Enter: Select item or confirm actionfunc 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)
}
func truncateID(id string, length int) string {
if len(id) > length {
return id[:length]
}
return id
}
IMPORTANT: Do not manually style statuses. Use the status utility functions:
// 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())
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.
Create 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
}
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
}
// 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}
}
}
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
}
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()
}
Add debug controls to your normal view footer:
controls := footerStyle.Render("↑/↓: Navigate | r: Refresh | d: Debug | q: Quit")
Benefits:
During development, create dummy data generators in your view file:
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)
See cmd/hatchet-cli/cli/tui/tasks.go for a complete implementation.
CRITICAL: Always ensure the CLI binary compiles before considering work complete.
After implementing or modifying any view:
# Build the CLI binary
go build -o /tmp/hatchet-test ./cmd/hatchet-cli
# Check for errors
echo $? # Should be 0 for success
UUID Type Mismatches
// ❌ 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), ...)
Required Imports
import (
"github.com/google/uuid"
openapi_types "github.com/oapi-codegen/runtime/types"
)
Type Conversions for API Params
*int64 not *int for offset/limittime.Time not *time.Time for Since parameter (check the generated types)openapi_types.UUID for tenant and workflow IDspkg/client/rest/gen.go for exact parameter typesPointer Helper Functions
func int64Ptr(i int64) *int64 {
return &i
}
Compilation Test
go build -o /tmp/hatchet-test ./cmd/hatchet-cli
Linting Test
After the build succeeds, run the linting checks:
task pre-commit-run
Continue running this command until it succeeds. Fix any linting issues that are reported before proceeding.
Basic Functionality Test
# Test with profile selection
/tmp/hatchet-test tui
# Test with specific profile
/tmp/hatchet-test tui --profile your-profile
Error Handling Test
Visual/Layout Testing
When implementing a new view:
frontend/app/src/pages/main/v1/cmd/hatchet-cli/cli/tui/{viewname}.gouuid and openapi_types if needed)BaseModelNewYourView(ctx ViewContext) constructorInit() methodUpdate(msg tea.Msg) methodView() method following standard structureSetSize(width, height int) methodRenderHeader(), RenderInstructions(), RenderFooter()RenderFooter()BaseModel.HandleError()*int64, time.Time, etc.)tui.gonewTUIModel() to instantiate your viewgo build ./cmd/hatchet-cli)RenderHeader, RenderInstructions, RenderFooter)ViewContext to access client and profile infov, nil for unhandled)v.Width == 0 before renderingRenderFooter()This section documents recent learnings and updates to maintain accuracy.
RenderHeaderWithViewIndicator() for primary views (shows just view name, non-repetitive)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:
// 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.
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:
// 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.
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:
// 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.
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:
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.
(This section will track potential improvements to the TUI system or this skill document)
[HINT] Download the complete skill directory including SKILL.md and all related files