build-tui-view

安装量: 55
排名: #13551

安装

npx skills add https://github.com/hatchet-dev/hatchet --skill build-tui-view
📝 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
-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
]
,
queryFn
:
async
(
)
=>
(
await
api
.
v1WorkflowRunList
(
tenant
,
query
)
)
.
data
,
}
)
,
}
Map to Go REST Client
The frontend
api.v1WorkflowRunList()
maps to Go's
client.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
,
}
,
)
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
:
// 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
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:
// 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.V1TaskSummary
rest.V1TaskSummaryList
rest.V1WorkflowRun
rest.V1WorkflowRunDetails
rest.Worker
rest.WorkerRuntimeInfo
rest.Workflow
rest.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 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:
// 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"
:
// 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
}
Step 5: Implement Debug View Rendering
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
(
)
}
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
)
,
}
,
// ... more dummy items
}
}
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:

Build the CLI binary

go build -o /tmp/hatchet-test ./cmd/hatchet-cli

Check for errors

echo $?

Should be 0 for success

Common Compilation Issues 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/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

Test with profile selection

/tmp/hatchet-test tui

Test with specific profile

/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
Find the corresponding frontend view
in
frontend/app/src/pages/main/v1/
Identify column structure and API calls from frontend
Note the exact API endpoint and parameters used
Creating the View
Create new file in
cmd/hatchet-cli/cli/tui/{viewname}.go
Add required imports (including
uuid
and
openapi_types
if needed)
Define view struct that embeds
BaseModel
Create
NewYourView(ctx ViewContext)
constructor
Implement
Init()
method
Implement
Update(msg tea.Msg)
method
Implement
View()
method following standard structure
Implement
SetSize(width, height int)
method
USE REUSABLE COMPONENTS
:
RenderHeader()
,
RenderInstructions()
,
RenderFooter()
DO NOT
copy-paste header/footer styling code
Apply Hatchet theme colors and styles for custom components only
Implement view-specific keyboard controls
Document all keyboard controls in footer using
RenderFooter()
Use appropriate REST API types
Add error handling using
BaseModel.HandleError()
Add responsive layout (handle WindowSizeMsg)
API Integration
Parse tenant ID to UUID if needed
Use correct parameter types (
*int64
,
time.Time
, etc.)
Handle API response errors
Format data for table display
Add loading states and error display
Integration and Testing
Import view in
tui.go
Update
newTUIModel()
to instantiate your view
Compile the CLI binary
(
go build ./cmd/hatchet-cli
)
Fix any compilation errors
Test basic functionality with real profile
Test error cases (no profile, invalid profile)
Test keyboard controls (q, r, arrows)
Update TUI command documentation
Best Practices
CRITICAL
Use reusable components (
RenderHeader
,
RenderInstructions
,
RenderFooter
)
NEVER
copy-paste header/footer styling code - this violates DRY principle
Keep view logic isolated in the view file
Use
ViewContext
to access client and profile info
Handle all messages gracefully (return
v, nil
for unhandled)
Always check
v.Width == 0
before rendering
Use consistent styling with other views (use Hatchet theme colors)
Document ALL keyboard controls in footer using
RenderFooter()
Follow the standard view structure (header, instructions, content, footer)
Always verify compilation before submitting
Post-Implementation
Update this skill document
with any new patterns or learnings discovered
Document any issues encountered and their solutions
Add any new utility functions or patterns to the appropriate sections
Verify all code examples and file paths are accurate
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:
// 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.
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:
// 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.
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:
// 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.
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"
:
// 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. Future Improvements (This section will track potential improvements to the TUI system or this skill document)
返回排行榜