| 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.
Overview
This skill provides instructions for creating and maintaining Terminal User Interface (TUI) views in the Hatchet CLI using bubbletea and lipgloss. The TUI system uses a modular view architecture where individual views are isolated in separate files within the views/ directory.
IMPORTANT: Always start by finding the corresponding view in the frontend application to understand the structure, columns, and API calls.
Self-Updating Skill Instructions
CRITICAL - READ FIRST: This skill document is designed to be continuously improved and kept accurate.
When to Update This Skill
You MUST update this skill file in the following situations:
-
Discovering Inaccuracies
- When you find incorrect file paths or directory structures
- When code examples don't compile or don't match actual implementations
- When API signatures have changed
- When referenced files don't exist at specified locations
-
Learning New Patterns
- When implementing a new view and discovering better approaches
- When the user teaches you new conventions or patterns
- When you find reusable patterns that should be documented
- When you discover common pitfalls that should be warned about
-
Finding Missing Information
- When you need information that isn't documented here
- When new components or utilities are added to the codebase
- When new bubbletea/lipgloss patterns are adopted
-
User Corrections
- When the user corrects any information in this document
- When the user provides updated approaches or conventions
- When the user points out outdated information
How to Update This Skill
When updating this skill:
- Verify Before Adding: Always verify paths, code, and API signatures against the actual codebase before adding to this document
- Use Read/Glob/Grep: Check the actual files to ensure accuracy
- Test Code Examples: Ensure code examples compile and follow current patterns
- Be Specific: Include exact file paths, function signatures, and working code examples
- Update Immediately: Make updates as soon as inaccuracies are discovered, not at the end of a session
- Preserve Structure: Maintain the existing document structure and formatting
- Add Context: When adding new sections, explain why the pattern is recommended
Verification Checklist
Before using information from this skill, verify:
Self-Correction Process
If you discover an inaccuracy while working:
- Immediately note the issue
- Verify the correct information by reading the actual files
- Update this skill document with the correction
- Continue with the user's task using the corrected information
Remember: This skill should be a living document that grows more accurate and comprehensive with each use.
Project Context
- Framework: bubbletea (TUI framework)
- Styling: lipgloss (style definitions)
- TUI Command Location:
cmd/hatchet-cli/cli/tui.go
- Views Location:
cmd/hatchet-cli/cli/tui/ directory
- Theme: Pre-defined Hatchet theme in
cmd/hatchet-cli/cli/internal/styles/styles.go
- Frontend Reference:
frontend/app/src/pages/main/v1/ directory
Finding Frontend Prior Art
CRITICAL FIRST STEP: Before implementing any TUI view, locate the corresponding frontend view to understand:
- Column structure and names
- API endpoints and query parameters
- Data types and fields used
- Filtering and sorting logic
Process for Finding Frontend Reference:
-
Locate the Frontend View
cd frontend/app/src/pages/main/v1/
ls -la
-
Study the Column Definitions
- Look for files like
{feature}-columns.tsx
- Note the column keys, titles, and accessors
- Example:
frontend/app/src/pages/main/v1/workflow-runs-v1/components/v1/task-runs-columns.tsx
export const TaskRunColumn = {
taskName: "Task Name",
status: "Status",
workflow: "Workflow",
createdAt: "Created At",
startedAt: "Started At",
duration: "Duration",
};
-
Identify the Data Hook
- Look for
use-{feature}.tsx files in the hooks/ directory
- These contain the API query logic
- Example:
frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-runs.tsx
-
Find the API Query
- Check
frontend/app/src/lib/api/queries.ts for the query definition
- Note the endpoint name and parameters
- Example:
v1WorkflowRuns: {
list: (tenant: string, query: V2ListWorkflowRunsQuery) => ({
queryKey: ['v1:workflow-run:list', tenant, query],
: () => ( api.(tenant, query)).,
}),
}
Example: Implementing Tasks View from Frontend Reference
-
Frontend Structure: frontend/app/src/pages/main/v1/workflow-runs-v1/
- Columns:
task-runs-columns.tsx
- Hook:
use-runs.tsx
- Table:
runs-table.tsx
-
Extract 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:
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},
}
response, err := client.API().V1WorkflowRunListWithResponse(
ctx,
client.TenantId(),
&rest.V1WorkflowRunListParams{
Offset: int64Ptr(0),
Limit: int64Ptr(100),
Since: &since,
OnlyTasks: true,
},
)
Reusable Components (CRITICAL - READ FIRST)
IMPORTANT: All TUI views MUST use the standardized reusable components defined in view.go to ensure consistency across the application. DO NOT copy-paste header/footer styling code.
Header Component
CRITICAL: ALL headers throughout the TUI use the magenta highlight color (styles.HighlightColor) for the title to provide consistent visual emphasis across all views (primary views, detail views, modals, etc.).
For Detail Views and Modals
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)
For Primary Views
Use RenderHeaderWithViewIndicator() for primary/list views:
header := RenderHeaderWithViewIndicator("Runs", v.Ctx.ProfileName, v.Width)
header := RenderHeaderWithViewIndicator("Workflows", v.Ctx.ProfileName, v.Width)
This function renders just the view name (e.g., "Runs" or "Workflows") in the highlight color, keeping it simple and non-repetitive.
Features of both header functions:
- Title rendered in magenta highlight color (
styles.HighlightColor) - consistent across ALL views
- Includes the logo (text-based: "HATCHET TUI") on the right
- Shows profile name
- Bordered bottom edge
- Responsive to terminal width
❌ NEVER do this:
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
BorderStyle(lipgloss.NormalBorder()).
header := headerStyle.Render(fmt.Sprintf("My View - Profile: %s", profile))
header := RenderHeaderWithLogo(fmt.Sprintf("My View - Profile: %s", profile), v.Width)
✅ ALWAYS do this:
header := RenderHeader("Task Details", v.Ctx.ProfileName, v.Width)
header := RenderHeaderWithViewIndicator("Runs", v.Ctx.ProfileName, v.Width)
Instructions Component
Use RenderInstructions() to display contextual help text:
instructions := RenderInstructions(
"Your instructions here • Use bullets to separate items",
v.Width,
)
Features:
- Muted color styling for reduced visual noise
- Automatically handles width constraints
- Consistent padding
- Uses bullet separators (•)
Footer Component
Always use RenderFooter() for navigation/control hints:
footer := RenderFooter([]string{
"↑/↓: Navigate",
"Enter: Select",
"Esc: Cancel",
"q: Quit",
}, v.Width)
Features:
- Consistent styling with top border
- Automatically joins control items with bullets (•)
- Muted color for non-intrusive display
- Responsive to terminal width
Standard View Structure
Every view should follow this consistent structure:
func (v *YourView) View() string {
var b strings.Builder
header := RenderHeader("View Title", v.Ctx.ProfileName, v.Width)
b.WriteString(header)
b.WriteString("\n\n")
instructions := RenderInstructions("Your instructions", v.Width)
b.WriteString(instructions)
b.WriteString("\n\n")
footer := RenderFooter([]string{
"control1: Action1",
"control2: Action2",
}, v.Width)
b.WriteString(footer)
return b.String()
}
Architecture
Root TUI Model (tui.go)
The root TUI command is responsible for:
- Profile selection and validation
- Initializing the Hatchet client
- Creating the view context
- Managing the current view
- Delegating updates to views
View System (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)
Core Principles
1. File Structure
TUI Command File
- File:
cmd/hatchet-cli/cli/tui.go
- Purpose: Command setup, profile selection, client initialization, view management
View Files
- Location:
cmd/hatchet-cli/cli/tui/
- Files:
view.go - View interface and base types
{viewname}.go - Individual view implementations
2. View Interface
All 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"
)
type ViewContext struct {
ProfileName string
Client client.Client
Width int
Height int
}
type View interface {
Init() tea.Cmd
Update(msg tea.Msg) (View, tea.Cmd)
View() string
SetSize(width, height int)
}
3. Base Model Pattern
Use BaseModel for common view fields:
type BaseModel struct {
Ctx ViewContext
Width int
Height int
Err error
}
type YourView struct {
BaseModel
table table.Model
items []YourDataType
}
4. Creating a New View
Step 1: Create View File
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
}
func NewYourView(ctx ViewContext) *YourView {
v := &YourView{
BaseModel: BaseModel{
Ctx: ctx,
},
}
return v
}
func (v *YourView) Init() tea.Cmd {
return nil
}
func (v *YourView) Update(msg tea.Msg) (View, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
return v, nil
case tea.KeyMsg:
switch msg.String() {
case "r":
return v, nil
}
}
return v, cmd
}
func (v *YourView) View() string {
if v.Width == 0 {
return "Initializing..."
}
return
}
SetSize(width, height ) {
v.BaseModel.SetSize(width, height)
}
Step 2: Use View in TUI
The root TUI model manages views:
func newTUIModel(profileName string, hatchetClient client.Client) tuiModel {
ctx := views.ViewContext{
ProfileName: profileName,
Client: hatchetClient,
}
currentView := views.NewYourView(ctx)
return tuiModel{
currentView: currentView,
}
}
5. Client Initialization Pattern
Always initialize the Hatchet client in tui.go:
import (
"github.com/rs/zerolog"
"github.com/hatchet-dev/hatchet/pkg/client"
)
profile, err := cli.GetProfile(selectedProfile)
if err != nil {
cli.Logger.Fatalf("could not get profile '%s': %v", selectedProfile, err)
}
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. Accessing the Client in Views
The Hatchet client is available through the view context:
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
client := v.Ctx.Client
return yourDataMsg{
data: data,
err: err,
}
}
}
7. Hatchet Theme Integration
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.
Available Theme Colors
import "github.com/hatchet-dev/hatchet/cmd/hatchet-cli/cli/internal/styles"
Status Rendering
Per-Cell Coloring in Tables: Use the custom TableWithStyleFunc wrapper to enable per-cell styling.
For status rendering in tables:
t := NewTableWithStyleFunc(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(20),
)
t.SetStyleFunc(func(row, col int) lipgloss.Style {
if col == 1 && row < len(v.tasks) {
statusStyle := styles.GetV1TaskStatusStyle(v.tasks[row].Status)
return lipgloss.NewStyle().Foreground(statusStyle.Foreground)
}
return lipgloss.NewStyle()
})
statusStyle := styles.GetV1TaskStatusStyle(task.Status)
status := statusStyle.Text
For non-table contexts (headers, footers, standalone text):
status := styles.RenderV1TaskStatus(task.Status)
errorMsg := styles.RenderError(fmt.Sprintf("Error: %v", err))
Why custom TableWithStyleFunc?
- Standard bubbles table doesn't support per-cell or per-column styling
TableWithStyleFunc wraps bubbles table and adds StyleFunc support
- StyleFunc allows dynamic cell styling based on row/column index
- Located in
cmd/hatchet-cli/cli/tui/table_custom.go
- Maintains bubbles table interactivity (cursor, selection, keyboard nav)
Table Styling
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(styles.AccentColor).
BorderBottom(true).
Bold(true).
Foreground(styles.AccentColor)
s.Selected = s.Selected.
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true)
Note: Use lipgloss.AdaptiveColor even for basic colors like white/black to support light/dark terminals.
Adding New Status Colors
If you need to add new status colors:
- Add the color constants to
cmd/hatchet-cli/cli/internal/styles/styles.go
- Create or update the utility function in
cmd/hatchet-cli/cli/internal/styles/status.go
- Reference the frontend badge variants in
frontend/app/src/components/v1/ui/badge.tsx for color values
- Use adaptive colors for light/dark terminal support
8. Standard Keyboard Controls
Use consistent key mappings across all views to provide a predictable user experience.
Global Controls (handled in tui.go)
q or ctrl+c: Quit the TUI
View-Specific Controls
Implement these in individual views:
- Navigation:
↑/↓ or arrow keys for list navigation
- Selection:
Enter to select/confirm
- Tab Navigation:
Tab/Shift+Tab for form fields
- Cancel:
Esc to go back/cancel
- Refresh:
r to manually refresh data
- Filter:
f to open filter modal (where applicable)
- Debug:
d to toggle debug view (see Debug Logging section)
- Clear:
c to clear debug logs (when in debug view)
- Tab Views:
1, 2, 3, etc. or tab/shift+tab for switching tabs
Important: Always document keyboard controls in the footer using RenderFooter()
9. Layout Components
CRITICAL: Use the reusable components from view.go for headers, instructions, and footers. See "Reusable Components" section above.
Header
✅ Use the reusable component:
header := RenderHeader("View Title", v.Ctx.ProfileName, v.Width)
❌ DO NOT manually create headers:
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
Footer
✅ Use the reusable component:
footer := RenderFooter([]string{
"↑/↓: Navigate",
"r: Refresh",
"q: Quit",
}, v.Width)
❌ DO NOT manually create footers:
footerStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
Instructions
✅ Use the reusable component:
instructions := RenderInstructions("Your helpful instructions here", v.Width)
Stats Bar
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,
))
10. Data Integration
REST API Types
Use generated REST types from:
import "github.com/hatchet-dev/hatchet/pkg/client/rest"
Common types:
rest.V1TaskSummary
rest.V1TaskSummaryList
rest.V1WorkflowRun
rest.V1WorkflowRunDetails
rest.Worker
rest.WorkerRuntimeInfo
rest.Workflow
rest.APIResourceMeta
Async Data Fetching Pattern
type yourDataMsg struct {
items []YourDataType
err error
}
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
}
}
case yourDataMsg:
v.loading = false
if msg.err != nil {
v.HandleError(msg.err)
} else {
v.items = msg.items
v.ClearError()
}
11. Modal Views
When creating modal overlays (like filter forms or confirmation dialogs):
- Still show the header with updated title using
RenderHeader()
- Show instructions specific to the modal interaction using
RenderInstructions()
- Show the modal content
- Show a footer with modal-specific controls using
RenderFooter()
Example Modal Structure:
func (v *TasksView) renderFilterModal() string {
var b strings.Builder
header := RenderHeader("Filter Tasks", v.Ctx.ProfileName, v.Width)
b.WriteString(header)
b.WriteString("\n\n")
instructions := RenderInstructions("Configure filters and press Enter to apply", v.Width)
b.WriteString(instructions)
b.WriteString("\n\n")
b.WriteString(v.filterForm.View())
b.WriteString("\n")
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.
12. Form Integration
When using huh forms in views:
- Set the Hatchet theme:
.WithTheme(styles.HatchetTheme())
- Integrate forms directly into the main tea.Program (don't run separate programs)
- Handle form completion by checking
form.State == huh.StateCompleted
- Pass ALL messages to the form when it's active (not just key messages)
Example:
import "github.com/charmbracelet/huh"
if v.showingFilter && v.filterForm != nil {
form, cmd := v.filterForm.Update(msg)
v.filterForm = form.(*huh.Form)
if v.filterForm.State == huh.StateCompleted {
v.showingFilter = false
}
return v, cmd
}
13. Table Component
Using github.com/charmbracelet/bubbles/table:
import "github.com/charmbracelet/bubbles/table"
columns := []table.Column{
{Title: "Column1", Width: 20},
{Title: "Column2", Width: 30},
}
t := table.New(
table.WithColumns(columns),
table.WithFocused(true),
table.WithHeight(20),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(styles.AccentColor).
BorderBottom(true).
Bold(true).
Foreground(styles.AccentColor)
s.Selected = s.Selected.
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true)
t.SetStyles(s)
rows := make([]table.Row, len(items))
for i, item := range items {
rows[i] = table.Row{item.Field1, item.Field2}
}
t.SetRows(rows)
14. Table Height Calculations and Layout Optimization
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.
Standard Height Calculations by View Type
Primary List Views (e.g., runs_list, workflows):
- Calculation:
height - 12
- Accounts for: header (3 lines), stats bar (2 lines), spacing (2 lines), footer (2 lines), buffer (3 lines)
func (v *RunsListView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
v.table.SetHeight(msg.Height - 12)
return v, nil
}
}
func (v *RunsListView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
if height > 12 {
v.table.SetHeight(height - 12)
}
}
Detail Views with Additional Info Sections (e.g., workflow_details with workflow info + runs table):
- Calculation:
height - 16 (or adjust based on info section size)
- Accounts for: header (3 lines), info section (4 lines), section header (2 lines), spacing (2 lines), footer (2 lines), buffer (3 lines)
func (v *WorkflowDetailsView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
v.SetSize(msg.Width, msg.Height)
v.table.SetHeight(msg.Height - 16)
return v, nil
}
}
func (v *WorkflowDetailsView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
if height > 16 {
v.table.SetHeight(height - 16)
}
}
Guidelines for Height Calculation
- Count Your UI Elements: List all elements that appear above and below the table
- Estimate Line Counts:
- Header: ~3 lines (with spacing)
- Stats bar: ~2 lines (with spacing)
- Section headers: ~2 lines each
- Info sections: ~3-5 lines depending on content
- Footer: ~2 lines (with spacing)
- Buffer: ~2-3 lines for safety
- Test at Different Sizes: Verify the table has adequate space at minimum terminal size (80x24)
- Iterate if Needed: If the table feels cramped, reduce the height offset by 2-4 lines
Common Mistake: Using the same height calculation for all views without accounting for additional UI elements.
❌ Wrong:
v.table.SetHeight(msg.Height - 12)
✅ Correct:
v.table.SetHeight(msg.Height - 16)
15. Column Consistency Between Related Views
CRITICAL: When a detail view displays a list that's conceptually similar to a primary list view (e.g., workflow details showing recent runs, same as the main runs list), the columns MUST match exactly to maintain consistency and user expectations.
Why Column Consistency Matters
- User Experience: Users expect the same information in the same format across views
- Cognitive Load: Consistent columns reduce mental overhead when switching contexts
- Visual Familiarity: Same column structure reinforces the relationship between views
Example: Runs List Columns
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):
columns := []table.Column{
{Title: "Task Name", Width: 30},
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25},
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12},
}
Implementing Column Consistency
When implementing a detail view with a related list:
- Reference the primary view: Check which columns the primary list view uses
- Copy the column structure exactly: Same titles, same order, same widths
- Keep all columns: Don't remove columns even if they seem redundant in the detail context
- Update row population: Ensure
updateTableRows() populates all columns correctly
❌ Wrong:
columns := []table.Column{
{Title: "Name", Width: 40},
{Title: "Created At", Width: 16},
{Title: "Status", Width: 12},
}
✅ Correct:
columns := []table.Column{
{Title: "Task Name", Width: 30},
{Title: "Status", Width: 12},
{Title: "Workflow", Width: 25},
{Title: "Created At", Width: 16},
{Title: "Started At", Width: 16},
{Title: "Duration", Width: 12},
}
16. View Navigation and Modal Selector
The TUI uses a navigation stack system for drilling down into details and a modal selector for switching between primary views.
Navigation Stack Pattern
The root TUI model maintains a viewStack for back navigation:
type tuiModel struct {
currentView tui.View
viewStack []tui.View
}
Navigating to a Detail View:
case tui.NavigateToWorkflowMsg:
m.viewStack = append(m.viewStack, m.currentView)
detailView := tui.NewWorkflowDetailsView(m.ctx, msg.WorkflowID)
detailView.SetSize(m.width, m.height)
m.currentView = detailView
return m, detailView.Init()
Navigating Back:
case tui.NavigateBackMsg:
if len(m.viewStack) > 0 {
m.currentView = m.viewStack[len(m.viewStack)-1]
m.viewStack = m.viewStack[:len(m.viewStack)-1]
m.currentView.SetSize(m.width, m.height)
}
return m, nil
In Detail Views (handle Esc key for back navigation):
case tea.KeyMsg:
switch msg.String() {
case "esc":
return v, NewNavigateBackMsg()
}
Modal View Selector Pattern
The modal selector allows switching between primary views using Shift+Tab:
Opening the Modal:
case tea.KeyMsg:
switch msg.String() {
case "shift+tab":
for i, opt := range availableViews {
if opt.Type == m.currentViewType {
m.selectedViewIndex = i
break
}
}
m.showViewSelector = true
return m, nil
}
Modal Navigation (supports Tab, arrow keys, vim keys):
if m.showViewSelector {
switch msg.String() {
case "shift+tab", "tab", "down", "j":
m.selectedViewIndex = (m.selectedViewIndex + 1) % len(availableViews)
return m, nil
case "up", "k":
m.selectedViewIndex = (m.selectedViewIndex - 1 + len(availableViews)) % len(availableViews)
return m, nil
case "enter":
selectedType := availableViews[m.selectedViewIndex].Type
if selectedType != m.currentViewType {
if m.isInPrimaryView() {
m.currentViewType = selectedType
m.currentView = m.createViewForType(selectedType)
m.currentView.SetSize(m.width, m.height)
m.showViewSelector = false
return m, m.currentView.Init()
}
}
m.showViewSelector = false
return m, nil
case "esc":
m.showViewSelector = false
return m, nil
}
return m, nil
}
Rendering the Modal:
func (m tuiModel) renderViewSelector() string {
var b strings.Builder
header := tui.RenderHeader("Select View", m.ctx.ProfileName, m.width)
b.WriteString(header)
b.WriteString("\n\n")
instructions := tui.RenderInstructions(
"↑/↓ or Tab: Navigate • Enter: Confirm • Esc: Cancel",
m.width,
)
b.WriteString(instructions)
b.WriteString("\n\n")
for i, opt := range availableViews {
if i == m.selectedViewIndex {
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#0A1029"}).
Background(styles.Blue).
Bold(true).
Padding(0, 2)
b.WriteString(selectedStyle.Render(fmt.Sprintf("▶ %s - %s", opt.Name, opt.Description)))
} else {
normalStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
Padding(0, 2)
b.WriteString(normalStyle.Render(fmt.Sprintf(" %s - %s", opt.Name, opt.Description)))
}
b.WriteString("\n")
}
footer := tui.RenderFooter([]string{
"Tab: Cycle",
"Enter: Confirm",
"Esc: Cancel",
}, m.width)
b.WriteString("\n")
b.WriteString(footer)
return b.String()
}
Key Principles:
- Navigation Stack: Use for hierarchical navigation (list → detail → back)
- Modal Selector: Use for switching between top-level views
- Primary View Check: Only allow view switching when not in a detail view
- Consistent Key Bindings:
Shift+Tab: Open view selector
Esc: Go back (in detail views) or cancel (in modals)
Enter: Select item or confirm action
- Arrow keys/vim keys: Navigate within lists and modals
Common Patterns
Formatting Utilities
Duration Formatting
func formatDuration(ms int) string {
duration := time.Duration(ms) * time.Millisecond
if duration < time.Second {
return fmt.Sprintf("%dms", ms)
}
seconds := duration.Seconds()
if seconds < 60 {
return fmt.Sprintf("%.1fs", seconds)
}
minutes := int(seconds / 60)
secs := int(seconds) % 60
return fmt.Sprintf("%dm%ds", minutes, secs)
}
ID Truncation
func truncateID(id string, length int) string {
if len(id) > length {
return id[:length]
}
return id
}
Status Rendering
IMPORTANT: Do not manually style statuses. Use the status utility functions:
status := styles.RenderV1TaskStatus(task.Status)
Auto-refresh Pattern
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
case tickMsg:
return v, tea.Batch(v.fetchData(), tick())
Debug Logging Pattern
Important: For views that make API calls or have complex state management, implement a debug logging system using a ring buffer to prevent memory leaks.
Step 1: Create Debug Logger (if not exists)
Create cmd/hatchet-cli/cli/tui/debug.go:
package views
import (
"fmt"
"sync"
"time"
)
type DebugLog struct {
Timestamp time.Time
Message string
}
type DebugLogger struct {
mu sync.RWMutex
logs []DebugLog
capacity int
index int
size int
}
func NewDebugLogger(capacity int) *DebugLogger {
return &DebugLogger{
logs: make([]DebugLog, capacity),
capacity: capacity,
index: 0,
size: 0,
}
}
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++
}
}
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 {
(result, d.logs[:d.size])
} {
n := (result, d.logs[d.index:])
(result[n:], d.logs[:d.index])
}
result
}
Clear() {
d.mu.Lock()
d.mu.Unlock()
d.index =
d.size =
}
Size() {
d.mu.RLock()
d.mu.RUnlock()
d.size
}
Capacity() {
d.capacity
}
Step 2: Integrate Debug Logger in Your View
type YourView struct {
BaseModel
debugLogger *DebugLogger
showDebug bool
}
func NewYourView(ctx ViewContext) *YourView {
v := &YourView{
BaseModel: BaseModel{
Ctx: ctx,
},
debugLogger: NewDebugLogger(5000),
showDebug: false,
}
v.debugLogger.Log("YourView initialized")
return v
}
Step 3: Add Debug Logging Throughout View
func (v *YourView) fetchData() tea.Cmd {
return func() tea.Msg {
v.debugLogger.Log("Fetching data...")
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}
}
}
Step 4: Add Toggle Key Handler
func (v *YourView) Update(msg tea.Msg) (View, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "d":
v.showDebug = !v.showDebug
v.debugLogger.Log("Debug view toggled: %v", v.showDebug)
return v, nil
case "c":
if v.showDebug {
v.debugLogger.Clear()
v.debugLogger.Log("Debug logs cleared")
}
return v, nil
}
}
}
Step 5: Implement Debug View Rendering
func (v *YourView) View() string {
if v.Width == 0 {
return "Initializing..."
}
if v.showDebug {
return v.renderDebugView()
}
}
func (v *YourView) renderDebugView() string {
logs := v.debugLogger.GetLogs()
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true).
BorderForeground(styles.AccentColor).
Width(v.Width-4).
Padding(0, 1)
header := headerStyle.Render(fmt.Sprintf(
"Debug Logs - %d/%d entries",
v.debugLogger.Size(),
v.debugLogger.Capacity(),
))
logStyle := lipgloss.NewStyle().
Padding(0, 1).
Width(v.Width - 4)
var b strings.Builder
b.WriteString(header)
b.WriteString("\n\n")
maxLines := v.Height - 8
if maxLines < 1 {
maxLines = 1
}
startIdx := 0
if len(logs) > maxLines {
startIdx = len(logs) - maxLines
}
for i := startIdx; i < len(logs); i++ {
log := logs[i]
timestamp := log.Timestamp.Format()
logLine := fmt.Sprintf(, timestamp, log.Message)
b.WriteString(logStyle.Render(logLine))
b.WriteString()
}
footerStyle := lipgloss.NewStyle().
Foreground(styles.MutedColor).
BorderStyle(lipgloss.NormalBorder()).
BorderTop().
BorderForeground(styles.AccentColor).
Width(v.Width).
Padding(, )
controls := footerStyle.Render()
b.WriteString()
b.WriteString(controls)
b.String()
}
Step 6: Update Footer Controls
Add debug controls to your normal view footer:
controls := footerStyle.Render("↑/↓: Navigate | r: Refresh | d: Debug | q: Quit")
Benefits:
- Fixed-size ring buffer prevents memory leaks
- Thread-safe with mutex protection
- Toggle on/off without restarting TUI
- Helps diagnose API issues and state changes
- No performance impact when not viewing logs
Testing Approach
Dummy Data Generation
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),
},
}
}
Example Reference
File Structure Example
cmd/hatchet-cli/cli/
├── tui.go # Root TUI command
└── views/
├── view.go # View interface and base types
├── tasks.go # Tasks view implementation
└── workflows.go # Workflows view implementation (future)
Complete View Example
See cmd/hatchet-cli/cli/tui/tasks.go for a complete implementation.
Compilation and Testing
CRITICAL: Always ensure the CLI binary compiles before considering work complete.
Compilation Check
After implementing or modifying any view:
go build -o /tmp/hatchet-test ./cmd/hatchet-cli
echo $?
Common Compilation Issues
-
UUID Type Mismatches
client.API().SomeMethod(ctx, client.TenantId(), ...)
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/limit
time.Time not *time.Time for Since parameter (check the generated types)
openapi_types.UUID for tenant and workflow IDs
- Check
pkg/client/rest/gen.go for exact parameter types
-
Pointer Helper Functions
func int64Ptr(i int64) *int64 {
return &i
}
Testing Workflow
-
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
/tmp/hatchet-test tui
/tmp/hatchet-test tui --profile your-profile
-
Error Handling Test
- Try without profiles configured
- Try with invalid profile
- Test keyboard controls (q, r, arrows)
-
Visual/Layout Testing
When implementing a new view:
- Test at various terminal sizes (minimum 80x24)
- Ensure header and footer are always visible
- Verify instructions are clear and helpful
- Check that navigation controls are consistent with other views
- Test with both light and dark terminal backgrounds
- Verify all reusable components render correctly
Checklist for New TUI Views
Before You Start
Creating the View
API Integration
Integration and Testing
Best Practices
Post-Implementation
Lessons Learned & Updates
This section documents recent learnings and updates to maintain accuracy.
Recent Updates
- 2026-01-10: Major updates based on workers view implementation and bug fixes:
- Added workers list view and worker details view as reference implementations
- Updated common REST API types list to include Worker, WorkerRuntimeInfo, Workflow
- Documented detail view header patterns (showing specific resource names in titles)
- Added section on filtering with multi-select forms and custom key maps
- Documented per-cell table styling using TableWithStyleFunc wrapper
- Added examples of status badge rendering in detail views
- Documented navigation messages (NavigateToWorkerMsg pattern)
- Added column alignment best practices (matching header format strings to row rendering)
- Workflow TUI implementation updates:
- Updated header component documentation: ALL headers (primary, detail, modal) now use highlight color for consistency
- Added
RenderHeaderWithViewIndicator() for primary views (shows just view name, non-repetitive)
- Added section 14: Table Height Calculations and Layout Optimization (height - 12 vs height - 16)
- Added section 15: Column Consistency Between Related Views (critical for UX)
- Added section 16: View Navigation and Modal Selector patterns
- Documented modal selector with Shift+Tab and arrow key support
- Documented navigation stack pattern for detail view drilling
- 2026-01-09: Added self-updating instructions and verification checklist
- Document initialized with comprehensive TUI view building guidelines
Known Issues & Solutions
Issue: Table Column Alignment Mismatches
Problem: Header columns don't align with table rows due to format string width mismatch.
Example: In run details tasks tab, header used %-3s for selector column but rows only rendered 2 characters ("▸ " or " "), causing status column and all subsequent columns to be misaligned.
Solution: Ensure header format string widths exactly match row rendering:
headerStyle.Render(fmt.Sprintf("%-2s %-30s %-12s", "", "NAME", "STATUS"))
if selected {
b.WriteString("▸ ")
} else {
b.WriteString(" ")
}
Prevention: Always count the exact characters rendered in rows and match header format widths precisely.
Issue: Detail View Headers Too Generic
Problem: Detail views showed generic titles like "Task Details" or "Workflow Run Details" without identifying the specific resource being viewed.
Solution: Include the resource name in the header title:
title := "Task Details"
if v.task != nil {
title = fmt.Sprintf("Task Details: %s", v.task.DisplayName)
}
title := "Workflow Details"
if v.workflow != nil {
title = fmt.Sprintf("Workflow Details: %s", v.workflow.Name)
}
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.
Issue: Filter Form Navigation Conflicts
Problem: Global Shift+Tab handler for view switching conflicts with form navigation, preventing Tab/Shift+Tab from working in filter modals.
Solution: Process filter form messages BEFORE checking global key handlers:
if v.showingFilter && v.filterForm != nil {
form, cmd := v.filterForm.Update(msg)
if f, ok := form.(*huh.Form); ok {
v.filterForm = f
if v.filterForm.State == huh.StateCompleted {
v.selectedStatuses = v.tempStatusFilters
v.showingFilter = false
v.updateTableRows()
return v, nil
}
if keyMsg, ok := msg.(tea.KeyMsg); ok {
if keyMsg.String() == "esc" {
v.showingFilter = false
return v, nil
}
}
}
return v, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "shift+tab":
}
}
Pattern: Always delegate to active modal/form components before processing global keyboard shortcuts.
Issue: Filtered Workers Not Reflected in Navigation
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":
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.
Future Improvements
(This section will track potential improvements to the TUI system or this skill document)