📝 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:
- File paths exist and are correct
- Code examples match current implementations
- API signatures match the generated REST client
- Reusable components are correctly referenced
- Directory structures are accurate
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
# Navigate to frontend pages cd frontend/app/src/pages/main/v1/ # Find views related to your feature (e.g., workflow-runs, tasks, events) ls -laStudy 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", };- Look for files like
Identify the Data Hook
- Look for
use-{feature}.tsxfiles in thehooks/directory - These contain the API query logic
- Example:
frontend/app/src/pages/main/v1/workflow-runs-v1/hooks/use-runs.tsx
- Look for
Find the API Query
- Check
frontend/app/src/lib/api/queries.tsfor the query definition - Note the endpoint name and parameters
- Example:
v1WorkflowRuns: { list: (tenant: string, query: V2ListWorkflowRunsQuery) => ({ queryKey: ['v1:workflow-run:list', tenant, query], queryFn: async () => (await api.v1WorkflowRunList(tenant, query)).data, }), }- Check
Map to Go REST Client
- The frontend
api.v1WorkflowRunList()maps to Go'sclient.API().V1WorkflowRunListWithResponse() - Frontend query parameters map to Go struct parameters
- Example mapping:
// 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, }, )- The frontend
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
- Columns:
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:
// 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, }, )
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:
// For primary list views - shows just the view name, no repetitive "Hatchet Workflows [Workflows]"
header := RenderHeaderWithViewIndicator("Runs", v.Ctx.ProfileName, v.Width)
header := RenderHeaderWithViewIndicator("Workflows", v.Ctx.ProfileName, v.Width)
This function renders just the view name (e.g., "Runs" or "Workflows") in the highlight color, keeping it simple and non-repetitive.
Features of both header functions:
- Title rendered in magenta highlight color (
styles.HighlightColor) - consistent across ALL views - Includes the logo (text-based: "HATCHET TUI") on the right
- Shows profile name
- Bordered bottom edge
- Responsive to terminal width
❌ NEVER do this:
// 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)
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
// 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()
}
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"
)
// 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. Base Model Pattern
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
}
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
// 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
}
Step 2: Use View in TUI
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,
}
}
5. Client Initialization Pattern
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)
}
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 {
// Access the client
client := v.Ctx.Client
// Make API calls
// response, err := client.API().SomeEndpoint(...)
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"
// 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
Status Rendering
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?
- Standard bubbles table doesn't support per-cell or per-column styling
TableWithStyleFuncwraps 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.tsxfor 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)
qorctrl+c: Quit the TUI
View-Specific Controls
Implement these in individual views:
- Navigation:
↑/↓or arrow keys for list navigation - Selection:
Enterto select/confirm - Tab Navigation:
Tab/Shift+Tabfor form fields - Cancel:
Escto go back/cancel - Refresh:
rto manually refresh data - Filter:
fto open filter modal (where applicable) - Debug:
dto toggle debug view (see Debug Logging section) - Clear:
cto clear debug logs (when in debug view) - Tab Views:
1,2,3, etc. ortab/shift+tabfor 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:
// Bad: Don't do this
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(styles.AccentColor).
// ... (this violates DRY principle)
Footer
✅ 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)
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.V1TaskSummaryrest.V1TaskSummaryListrest.V1WorkflowRunrest.V1WorkflowRunDetailsrest.Workerrest.WorkerRuntimeInforest.Workflowrest.APIResourceMeta
Async Data Fetching Pattern
// 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. 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
// 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.
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"
// 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. Table Component
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)
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) // Primary view calculation
return v, nil
}
// ...
}
func (v *RunsListView) SetSize(width, height int) {
v.BaseModel.SetSize(width, height)
if height > 12 {
v.table.SetHeight(height - 12)
}
}
Detail Views with Additional Info Sections (e.g., workflow_details with workflow info + runs table):
- Calculation:
height - 16(or adjust based on info section size) - Accounts for: header (3 lines), info section (4 lines), section header (2 lines), spacing (2 lines), footer (2 lines), buffer (3 lines)
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)
}
}
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:
// 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
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):
// 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},
}
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:
// 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
}
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 // 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()
}
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":
// 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:
- 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 selectorEsc: 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:
// 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
Auto-refresh Pattern
// 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())
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"
)
// 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
}
Step 2: Integrate Debug Logger in Your View
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
}
Step 3: Add Debug Logging Throughout View
// 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}
}
}
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":
…(truncated)