backend/docs/installer.md
The PentAGI installer provides a robust Terminal User Interface (TUI) for configuring the application. Built using the Charm tech stack (bubbletea, lipgloss, bubbles), it implements modern responsive design patterns optimized for terminal environments.
logger.Log() (writes to log.json) - never fmt.Printftail -f log.json in separate terminalfunc (m *FormModel) addBooleanField(key, title, description string, envVar loader.EnvVar) {
input := textinput.New()
input.Prompt = ""
input.PlaceholderStyle = m.styles.FormPlaceholder
input.ShowSuggestions = true
input.SetSuggestions([]string{"true", "false"}) // Tab completion
// Show default in placeholder
if envVar.Default == "true" {
input.Placeholder = "true (default)"
} else {
input.Placeholder = "false (default)"
}
// Set value only if actually present in environment
if envVar.Value != "" && envVar.IsPresent() {
input.SetValue(envVar.Value)
}
}
// Tab completion handler
func (m *FormModel) completeSuggestion() {
if m.focusedIndex < len(m.fields) {
suggestion := m.fields[m.focusedIndex].Input.CurrentSuggestion()
if suggestion != "" {
m.fields[m.focusedIndex].Input.SetValue(suggestion)
m.fields[m.focusedIndex].Input.CursorEnd()
m.hasChanges = true
m.updateFormContent()
}
}
}
func (m *FormModel) addIntegerField(key, title, description string, envVar loader.EnvVar, min, max int) {
input := textinput.New()
input.Prompt = ""
input.PlaceholderStyle = m.styles.FormPlaceholder
// Parse and format default value
defaultValue := 0
if envVar.Default != "" {
if val, err := strconv.Atoi(envVar.Default); err == nil {
defaultValue = val
}
}
// Human-readable placeholder with default
input.Placeholder = fmt.Sprintf("%s (%s default)",
m.formatNumber(defaultValue), m.formatBytes(defaultValue))
// Add validation range to description
fullDescription := fmt.Sprintf("%s (Range: %s - %s)",
description, m.formatBytes(min), m.formatBytes(max))
}
// Real-time validation
func (m *FormModel) validateField(index int) {
field := &m.fields[index]
value := field.Input.Value()
if intVal, err := strconv.Atoi(value); err != nil {
field.Input.Placeholder = "Enter a valid number or leave empty for default"
} else {
// Check ranges with human-readable feedback
if intVal < min || intVal > max {
field.Input.Placeholder = fmt.Sprintf("Range: %s - %s",
m.formatBytes(min), m.formatBytes(max))
} else {
field.Input.Placeholder = "" // Clear error
}
}
}
// Universal byte formatting
func (m *FormModel) formatBytes(bytes int) string {
if bytes >= 1048576 {
return fmt.Sprintf("%.1fMB", float64(bytes)/1048576)
} else if bytes >= 1024 {
return fmt.Sprintf("%.1fKB", float64(bytes)/1024)
}
return fmt.Sprintf("%d bytes", bytes)
}
// Universal number formatting
func (m *FormModel) formatNumber(num int) string {
if num >= 1000000 {
return fmt.Sprintf("%.1fM", float64(num)/1000000)
} else if num >= 1000 {
return fmt.Sprintf("%.1fK", float64(num)/1000)
}
return strconv.Itoa(num)
}
// Helper to create field from EnvVar
addFieldFromEnvVar := func(suffix, key, title, description string) {
envVar, _ := m.controller.GetVar(m.getEnvVarName(suffix))
// Track if field was initially set for cleanup logic
m.initiallySetFields[key] = envVar.Value != ""
if key == "preserve_last" || key == "use_qa" {
m.addBooleanField(key, title, description, envVar)
} else {
// Determine validation ranges
var min, max int
switch key {
case "last_sec_bytes", "max_qa_bytes":
min, max = 1024, 1048576 // 1KB to 1MB
case "max_bp_bytes":
min, max = 1024, 524288 // 1KB to 512KB
default:
min, max = 0, 999999
}
m.addIntegerField(key, title, description, envVar, min, max)
}
}
func (m *FormModel) saveConfiguration() (tea.Model, tea.Cmd) {
// First pass: Handle fields that were cleared (remove from environment)
for _, field := range m.fields {
value := field.Input.Value()
// If field was initially set but now empty, remove it
if value == "" && m.initiallySetFields[field.Key] {
envVarName := m.getEnvVarName(getEnvSuffixFromKey(field.Key))
// Remove the environment variable
if err := m.controller.SetVar(envVarName, ""); err != nil {
logger.Errorf("[FormModel] SAVE: error clearing %s: %v", envVarName, err)
return m, nil
}
logger.Log("[FormModel] SAVE: cleared %s", envVarName)
}
}
// Second pass: Save only non-empty values
for _, field := range m.fields {
value := field.Input.Value()
if value == "" {
continue // Skip empty values - use defaults
}
// Validate and save
envVarName := m.getEnvVarName(getEnvSuffixFromKey(field.Key))
if err := m.controller.SetVar(envVarName, value); err != nil {
logger.Errorf("[FormModel] SAVE: error setting %s: %v", envVarName, err)
return m, nil
}
}
m.hasChanges = false
return m, nil
}
func (m *FormModel) calculateTokenEstimate() string {
// Get current form values or defaults
useQAVal := m.getBoolValueOrDefault("use_qa")
lastSecBytesVal := m.getIntValueOrDefault("last_sec_bytes")
maxQABytesVal := m.getIntValueOrDefault("max_qa_bytes")
keepQASectionsVal := m.getIntValueOrDefault("keep_qa_sections")
var estimatedBytes int
// Algorithm-specific calculations
if m.summarizerType == "assistant" {
estimatedBytes = keepQASectionsVal * lastSecBytesVal
} else {
if useQAVal {
basicSize := keepQASectionsVal * lastSecBytesVal
if basicSize > maxQABytesVal {
estimatedBytes = maxQABytesVal
} else {
estimatedBytes = basicSize
}
} else {
estimatedBytes = keepQASectionsVal * lastSecBytesVal
}
}
// Convert to tokens with overhead
estimatedTokens := int(float64(estimatedBytes) * 1.1 / 4) // 4 bytes per token + 10% overhead
return fmt.Sprintf("~%s tokens", m.formatNumber(estimatedTokens))
}
// Helper methods to get form values or defaults
func (m *FormModel) getBoolValueOrDefault(key string) bool {
// First check form field value
for _, field := range m.fields {
if field.Key == key && field.Input.Value() != "" {
return field.Input.Value() == "true"
}
}
// Return default value from EnvVar
envVar, _ := m.controller.GetVar(m.getEnvVarName(getEnvSuffixFromKey(key)))
return envVar.Default == "true"
}
func (m *TypesModel) renderTypeInfo() string {
selectedType := m.types[m.selectedIndex]
// Helper functions for value retrieval
getIntValue := func(varName string) int {
var prefix string
if selectedType.ID == "assistant" {
prefix = "ASSISTANT_SUMMARIZER_"
} else {
prefix = "SUMMARIZER_"
}
envVar, _ := m.controller.GetVar(prefix + varName)
if envVar.Value != "" {
if val, err := strconv.Atoi(envVar.Value); err == nil {
return val
}
}
// Use default if value is empty or invalid
if val, err := strconv.Atoi(envVar.Default); err == nil {
return val
}
return 0
}
// Display current configuration
sections = append(sections, m.styles.Subtitle.Render("Current Configuration"))
sections = append(sections, "")
lastSecBytes := getIntValue("LAST_SEC_BYTES")
maxBPBytes := getIntValue("MAX_BP_BYTES")
preserveLast := getBoolValue("PRESERVE_LAST")
sections = append(sections, fmt.Sprintf("• Last Section Size: %s", formatBytes(lastSecBytes)))
sections = append(sections, fmt.Sprintf("• Max Body Pair Size: %s", formatBytes(maxBPBytes)))
sections = append(sections, fmt.Sprintf("• Preserve Last: %t", preserveLast))
// Type-specific fields
if selectedType.ID == "general" {
useQA := getBoolValue("USE_QA")
sections = append(sections, fmt.Sprintf("• Use QA Pairs: %t", useQA))
}
// Token estimation in info panel
sections = append(sections, "")
sections = append(sections, m.styles.Subtitle.Render("Token Estimation"))
sections = append(sections, fmt.Sprintf("• Estimated context size: ~%s tokens", formatNumber(estimatedTokens)))
return strings.Join(sections, "\n")
}
// Field-specific descriptions with validation hints
const (
SummarizerFormLastSecBytes = "Last Section Size (bytes)"
SummarizerFormLastSecBytesDesc = "Maximum byte size for each preserved conversation section"
// Enhanced help with practical guidance
SummarizerFormGeneralHelp = `Balance information depth vs model performance.
Reduce these settings if:
• Using models with ≤64K context (Open Source Reasoning Models)
• Getting "context too long" errors
• Responses become vague or unfocused with long conversations
Key Settings Impact:
• Last Section Size: Larger = more detail, but uses more tokens
• Keep QA Sections: More sections = better continuity, higher token usage
Recommended Adjustments:
• Open Source Reasoning Models: Reduce Last Section to 25-35KB, Keep QA to 1
• OpenAI/Anthropic/Google: Default settings work well`
)
func (m *FormModel) buildForm() {
// Set type-specific name
switch m.summarizerType {
case "general":
m.typeName = locale.SummarizerTypeGeneralName
case "assistant":
m.typeName = locale.SummarizerTypeAssistantName
}
// Common fields for all types
addFieldFromEnvVar("PRESERVE_LAST", "preserve_last", locale.SummarizerFormPreserveLast, locale.SummarizerFormPreserveLastDesc)
// Type-specific fields
if m.summarizerType == "general" {
addFieldFromEnvVar("USE_QA", "use_qa", locale.SummarizerFormUseQA, locale.SummarizerFormUseQADesc)
addFieldFromEnvVar("SUM_MSG_HUMAN_IN_QA", "sum_human_in_qa", locale.SummarizerFormSumHumanInQA, locale.SummarizerFormSumHumanInQADesc)
}
// Common configuration fields
addFieldFromEnvVar("LAST_SEC_BYTES", "last_sec_bytes", locale.SummarizerFormLastSecBytes, locale.SummarizerFormLastSecBytesDesc)
// ... additional fields
}
These patterns provide a robust foundation for implementing advanced configuration forms with:
Ready Patterns: Based on locale constants, implement:
LANGFUSE_* prefix pattern with cleanup// Implementation pattern
func (m *LangfuseFormModel) buildForm() {
// Deployment type field (radio-style selection)
m.addDeploymentTypeField("deployment_type", locale.LangfuseDeploymentType, locale.LangfuseDeploymentTypeDesc)
// Conditional fields based on deployment type
if m.deploymentType == "external" {
m.addFieldFromEnvVar("LANGFUSE_BASE_URL", "base_url", locale.LangfuseBaseURL, locale.LangfuseBaseURLDesc)
m.addFieldFromEnvVar("LANGFUSE_PROJECT_ID", "project_id", locale.LangfuseProjectID, locale.LangfuseProjectIDDesc)
m.addFieldFromEnvVar("LANGFUSE_PUBLIC_KEY", "public_key", locale.LangfusePublicKey, locale.LangfusePublicKeyDesc)
m.addMaskedFieldFromEnvVar("LANGFUSE_SECRET_KEY", "secret_key", locale.LangfuseSecretKey, locale.LangfuseSecretKeyDesc)
} else if m.deploymentType == "embedded" {
// Admin configuration for embedded instance
m.addFieldFromEnvVar("LANGFUSE_ADMIN_EMAIL", "admin_email", locale.LangfuseAdminEmail, locale.LangfuseAdminEmailDesc)
m.addMaskedFieldFromEnvVar("LANGFUSE_ADMIN_PASSWORD", "admin_password", locale.LangfuseAdminPassword, locale.LangfuseAdminPasswordDesc)
m.addFieldFromEnvVar("LANGFUSE_ADMIN_NAME", "admin_name", locale.LangfuseAdminName, locale.LangfuseAdminNameDesc)
}
}
Ready Patterns: Monitoring stack configuration with similar architecture:
// Environment variables pattern
func (m *ObservabilityFormModel) getEnvVarName(suffix string) string {
if m.deploymentType == "external" {
return "OTEL_" + suffix
}
return "OBSERVABILITY_" + suffix
}
Potential Patterns: Based on established architecture:
AI-Powered Diagnostics:
type TypesModel struct {
controller *controllers.StateController
types []TypeInfo
selectedIndex int
args []string
}
// Universal type info structure
type TypeInfo struct {
ID string
Name string
Description string
}
// Current configuration display (reusable pattern)
func (m *TypesModel) renderCurrentConfiguration(selectedType TypeInfo) string {
sections = append(sections, m.styles.Subtitle.Render("Current Configuration"))
// Type-specific value retrieval
getValue := func(suffix string) string {
envVar, _ := m.controller.GetVar(m.getEnvVarName(selectedType.ID, suffix))
if envVar.Value != "" {
return envVar.Value
}
return envVar.Default + " (default)"
}
// Display current settings with formatting
sections = append(sections, fmt.Sprintf("• Setting 1: %s", getValue("SETTING_1")))
sections = append(sections, fmt.Sprintf("• Setting 2: %s", formatBytes(getIntValue("SETTING_2"))))
return strings.Join(sections, "\n")
}
type FormModel struct {
// Standard form architecture
controller *controllers.StateController
configType string
fields []FormField
initiallySetFields map[string]bool
viewport viewport.Model
// Pattern-specific additions
resourceEstimation string
validationErrors map[string]string
}
// Universal form building
func (m *FormModel) buildForm() {
m.fields = []FormField{}
m.initiallySetFields = make(map[string]bool)
// Type-specific field generation
switch m.configType {
case "type1":
m.addCommonFields()
m.addType1SpecificFields()
case "type2":
m.addCommonFields()
m.addType2SpecificFields()
}
// Focus and content update
if len(m.fields) > 0 {
m.fields[0].Input.Focus()
}
m.updateFormContent()
}
// Resource calculation (reusable pattern)
func (m *FormModel) calculateResourceEstimate() string {
// Get current values from form or defaults
setting1 := m.getIntValueOrDefault("setting1")
setting2 := m.getBoolValueOrDefault("setting2")
// Algorithm-specific calculation
var estimate int
if setting2 {
estimate = setting1 * 2
} else {
estimate = setting1
}
return fmt.Sprintf("~%s", m.formatNumber(estimate))
}
These templates ensure consistency across all future configuration screens while leveraging the proven patterns from summarizer and LLM provider implementations.
contentWidth < (MinMenuWidth + MinInfoWidth + 8)§ separator (llm_provider_form§openai)"screen" or "screen§arg1§arg2§..." for parameterized screensGetScreen(), GetArgs(), CreateScreenID()// Example: LLM Provider Form with specific provider
targetScreen := CreateScreenID("llm_provider_form", "gemini")
// Results in: "llm_provider_form§gemini"
// Navigation preserves arguments
return NavigationMsg{Target: targetScreen}
// On app restart, user returns to Gemini form, not default OpenAI
welcome.go, eula.go, main_menu.go, etc.types.go for type safetylocale/locale.goCentral Orchestrator (app.go):
// UNIFIED RENDERING - All screens follow this pattern:
func (a *App) View() string {
header := a.renderHeader() // Screen-specific header
footer := a.renderFooter() // Dynamic footer with actions
content := a.currentModel.View() // Model provides content only
// App.go calculates and enforces layout constraints
contentHeight := max(height - headerHeight - footerHeight, 0)
contentArea := a.styles.Content.Height(contentHeight).Render(content)
return lipgloss.JoinVertical(lipgloss.Left, header, contentArea, footer)
}
ScreenID with CreateScreenID() for parameterized screens§ separatorGetEulaConsent() on Welcome→EULA transition, call SetEulaConsent() on acceptance// Type-safe navigation structure with composite support
type NavigationMsg struct {
Target ScreenID // Can be simple or composite
GoBack bool
}
type ScreenID string
const (
WelcomeScreen ScreenID = "welcome"
EULAScreen ScreenID = "eula"
MainMenuScreen ScreenID = "main_menu"
LLMProviderFormScreen ScreenID = "llm_provider_form"
)
// ScreenID methods for composite support
func (s ScreenID) GetScreen() string {
parts := strings.Split(string(s), "§")
return parts[0]
}
func (s ScreenID) GetArgs() []string {
parts := strings.Split(string(s), "§")
if len(parts) <= 1 {
return []string{}
}
return parts[1:]
}
// Navigation with parameters
targetScreen := CreateScreenID("llm_provider_form", "anthropic")
return NavigationMsg{Target: targetScreen}
// Universal ESC implementation
case "esc":
if a.navigator.Current().GetScreen() != string(models.WelcomeScreen) {
a.navigator.stack = []models.ScreenID{models.WelcomeScreen}
a.navigator.stateManager.SetStack([]string{"welcome"})
a.currentModel = a.createModelForScreen(models.WelcomeScreen, nil)
return a, a.currentModel.Init()
}
state.State remains authoritative for env variablesInit() for predictable behavior// Layout Constants
const (
SmallScreenThreshold = 30 // Height threshold for viewport mode
MinTerminalWidth = 80 // Minimum width for horizontal layout
MinPanelWidth = 25 // Panel width constraints
WelcomeHeaderHeight = 8 // Fixed by ASCII Art Logo (8 lines)
EULAHeaderHeight = 3 // Title + subtitle + spacing
FooterHeight = 1 // Always 1 line with background approach
)
// ❌ WRONG - Height() sets MINIMUM height, can expand
style.Height(1).Border(lipgloss.Border{Top: true})
// ✅ CORRECT - Background approach ensures exactly 1 line
style.Background(borderColor).Foreground(textColor).Padding(0,1,0,1)
Background Approach (Production-Ready):
// Footer pattern implementation
actions := locale.BuildCommonActions()
if specificCondition {
actions = append(actions, locale.SpecificAction)
}
footerText := strings.Join(actions, locale.NavSeparator)
return lipgloss.NewStyle().
Width(width).
Background(styles.Border).
Foreground(styles.Foreground).
Padding(0, 1, 0, 1).
Render(footerText)
app.goviewport.ScrollUp(1) // Replaces deprecated LineUp()
viewport.ScrollDown(1) // Replaces deprecated LineDown()
viewport.ScrollLeft(2) // Horizontal scroll (2 steps for faster navigation)
viewport.ScrollRight(2) // Horizontal scroll (2 steps for faster navigation)
// Single renderer instance in styles.New()
type Styles struct {
renderer *glamour.TermRenderer
width int
height int
}
// Usage pattern
rendered, err := m.styles.GetRenderer().Render(markdown)
if err != nil {
// Fallback to plain text
rendered = fmt.Sprintf("# Content\n\n%s\n\n*Render error: %v*", content, err)
}
Init() for clean transitionsfiles.GetContent() - handles working directory variationswizard/locale/
└── locale.go # All user-visible text constants
Naming Convention:
Welcome*, EULA*, Menu*, LLM*, Checks* - Screen-specificNav*, Status*, Error*, UI* - Functional prefixesm.styles.GetSize()// REQUIRED: State reset in Init()
func (m *Model) Init() tea.Cmd {
logger.Log("[Model] INIT")
m.content = ""
m.ready = false
// ... reset ALL state
return m.loadContent
}
// REQUIRED: Dimension handling via styles
func (m *Model) updateViewport() {
width, height := m.styles.GetSize()
if width <= 0 || height <= 0 {
return
}
// ... viewport logic
}
// REQUIRED: Adaptive layout methods
func (m *Model) isVerticalLayout() bool {
return m.styles.GetWidth() < MinTerminalWidth
}
For each new screen:
ScreenID defined in types.goInit() method with loggerm.styles.GetSize()Scroll* methods for navigationstyles.GetRenderer()// ✅ Compact where appropriate:
leftWidth = max(leftWidth, MinPanelWidth)
return lipgloss.NewStyle().Width(width).Padding(0, 2, 0, 2).Render(content)
// ✅ Expanded where needed:
coreChecks := []struct {
label string
value bool
}{
{locale.CheckEnvironmentFile, m.checker.EnvFileExists},
{locale.CheckDockerAPI, m.checker.DockerApiAccessible},
}
Problem: Need to preserve selected menu items and provider selections across navigation
Solution: Implemented composite ScreenIDs with § separator for parameter passing
Features:
// Composite ScreenID examples
"main_menu§llm_providers" // Main menu with "llm_providers" selected
"llm_providers§gemini" // Providers list with "gemini" selected
"llm_provider_form§anthropic" // Form for "anthropic" provider
Benefits:
GetScreen(), GetArgs(), CreateScreenID()Problem: Hardcoded strings scattered throughout UI components
Solution: Centralized all user-visible text in locale.go with structured constants
Implementation:
// Multi-line text stored as single constants
const MainMenuLLMProvidersInfo = `Configure AI language model providers for PentAGI.
Supported providers:
• OpenAI (GPT-4, GPT-3.5-turbo)
• Anthropic (Claude-3, Claude-2)
...`
// Usage in components
sections = append(sections, m.styles.Paragraph.Render(locale.MainMenuLLMProvidersInfo))
Coverage: 100% of user-facing text moved to locale constants
Problem: Forms with many fields don't fit on smaller terminals Solution: Implemented auto-scrolling viewport with focus tracking
Based on research: BubbleTea viewport best practices and Perplexity guidance on form scrolling
Key Features:
Technical Implementation:
// Auto-scroll on field focus change
func (m *Model) ensureFocusVisible() {
focusY := m.calculateFieldPosition(m.focusedIndex)
if focusY < m.viewport.YOffset {
m.viewport.YOffset = focusY // Scroll up
}
if focusY >= m.viewport.YOffset + m.viewport.Height {
m.viewport.YOffset = focusY - m.viewport.Height + 1 // Scroll down
}
}
Problem: Missing configuration fields for several LLM providers Solution: Added complete field sets for all supported providers
Provider Field Mapping:
Dynamic Form Generation: Forms adapt based on provider type with appropriate validation and help text.
Graceful degradation with user-friendly messages:
Lazy loading approach:
Problem: "Loading EULA" state persists, content doesn't appear Solutions:
Init() for clean loadingtea.ClearScreen during navigationProblem: Layout breaks on terminal resize Solution: Always account for actual footer height (1 line)
// Consistent height calculation across all screens
headerHeight := 3 // Fixed based on content
footerHeight := 1 // Background approach always 1 line
contentHeight := m.height - headerHeight - footerHeight
tea.ClearScreen in navigationInit()fmt.Printf for debug outputLine* scroll methodstype LLMProviderFormModel struct {
controller *controllers.StateController
styles *styles.Styles
window *window.Window
// Form state
providerID string
fields []FormField
focusedIndex int
showValues bool
hasChanges bool
args []string // From composite ScreenID
// Permanent viewport for scroll state
viewport viewport.Model
formContent string
fieldHeights []int
}
Key Implementation Decisions:
NewLLMProviderFormModel(controller, styles, window, args)return NavigationMsg{GoBack: true} prevents navigation loops// Modern form navigation
case "down": // ↓: Next field + auto-scroll
case "up": // ↑: Previous field + auto-scroll
case "tab": // Tab: Complete suggestion (true/false for booleans)
case "ctrl+h": // Ctrl+H: Toggle show/hide masked values
case "ctrl+s": // Ctrl+S: Save configuration
case "enter": // Enter: Save and return via GoBack
Important: Tab navigation replaced with suggestion completion. Field navigation uses ↑/↓ only.
const (
MinMenuWidth = 38 // Minimum left panel width
MaxMenuWidth = 66 // Maximum left panel width (prevents too wide forms)
MinInfoWidth = 34 // Minimum right panel width
PaddingWidth = 8 // Total horizontal padding
PaddingHeight = 2 // Vertical padding
)
func (m *Model) renderHorizontalLayout(leftPanel, rightPanel string, width, height int) string {
leftWidth, rightWidth := MinMenuWidth, MinInfoWidth
extraWidth := width - leftWidth - rightWidth - PaddingWidth
// Distribute extra space intelligently
if extraWidth > 0 {
leftWidth = min(leftWidth+extraWidth/2, MaxMenuWidth) // Cap at MaxMenuWidth
rightWidth = width - leftWidth - PaddingWidth/2
}
leftStyled := lipgloss.NewStyle().Width(leftWidth).Padding(0, 2, 0, 2).Render(leftPanel)
rightStyled := lipgloss.NewStyle().Width(rightWidth).PaddingLeft(2).Render(rightPanel)
// Final layout viewport (temporary)
viewport := viewport.New(width, height-PaddingHeight)
viewport.SetContent(lipgloss.JoinHorizontal(lipgloss.Top, leftStyled, rightStyled))
return viewport.View()
}
func (m *Model) renderVerticalLayout(leftPanel, rightPanel string, width, height int) string {
verticalStyle := lipgloss.NewStyle().Width(width).Padding(0, 4, 0, 2)
leftStyled := verticalStyle.Render(leftPanel)
rightStyled := verticalStyle.Render(rightPanel)
// Show both panels if they fit
if lipgloss.Height(leftStyled)+lipgloss.Height(rightStyled)+2 < height {
return lipgloss.JoinVertical(lipgloss.Left,
leftStyled,
verticalStyle.Height(1).Render(""),
rightStyled,
)
}
// Hide right panel if insufficient space - show only essential content
return leftStyled
}
// Navigation with selection preservation
func (m *MainMenuModel) handleMenuSelection() (tea.Model, tea.Cmd) {
selectedItem := m.getSelectedItem()
return m, func() tea.Msg {
return NavigationMsg{
Target: CreateScreenID(string(targetScreen), selectedItem.ID),
}
}
}
// Result: "llm_providers§openai" -> llm_providers screen with "openai" pre-selected
// No SetSelected* methods needed - selection from constructor
func NewLLMProvidersModel(
controller *controllers.StateController, styles *styles.Styles,
window *window.Window, args []string,
) *LLMProvidersModel {
return &LLMProvidersModel{
controller: controller,
args: args, // Selection restored from args in Init()
}
}
func (m *LLMProvidersModel) Init() tea.Cmd {
// Automatic selection restoration from args[1]
if len(m.args) > 1 && m.args[1] != "" {
for i, provider := range m.providers {
if provider.ID == m.args[1] {
m.selectedIndex = i
break
}
}
}
return nil
}
Stack Example: ["main_menu§llm_providers", "llm_providers§openai", "llm_provider_form§openai"]
GoBack: true pops current screen, returns to previous with preserved selection// ✅ CORRECT: Form viewport as struct property
type FormModel struct {
viewport viewport.Model // Preserves scroll position across updates
}
func (m *FormModel) ensureFocusVisible() {
// Auto-scroll to focused field
focusY := m.calculateFieldPosition(m.focusedIndex)
if focusY < m.viewport.YOffset {
m.viewport.YOffset = focusY
}
if focusY+m.fieldHeights[m.focusedIndex] >= offset+visibleRows {
m.viewport.YOffset = focusY + m.fieldHeights[m.focusedIndex] - visibleRows + 1
}
}
// ✅ CORRECT: Layout viewport created for rendering only
func (m *Model) renderHorizontalLayout(left, right string, width, height int) string {
content := lipgloss.JoinHorizontal(lipgloss.Top, leftStyled, rightStyled)
vp := viewport.New(width, height-PaddingHeight) // Temporary
vp.SetContent(content)
return vp.View()
}
// Clean input setup without fixed width
func (m *FormModel) addInputField(fieldType string) {
input := textinput.New()
input.Prompt = "" // Clean appearance
input.PlaceholderStyle = m.styles.FormPlaceholder
// Width set dynamically during updateFormContent()
// NOT set here: input.Width = 50
if fieldType == "boolean" {
input.ShowSuggestions = true
input.SetSuggestions([]string{"true", "false"})
}
}
func (m *FormModel) getInputWidth() int {
viewportWidth, _ := m.getViewportSize()
inputWidth := viewportWidth - 6 // Standard padding
if m.isVerticalLayout() {
inputWidth = viewportWidth - 4 // Tighter in vertical mode
}
return inputWidth
}
// Applied during form content update
func (m *FormModel) updateFormContent() {
inputWidth := m.getInputWidth()
for i, field := range m.fields {
field.Input.Width = inputWidth - 3 // Account for border/cursor
field.Input.SetValue(field.Input.Value()) // Trigger width update
inputStyle := m.styles.FormInput.Width(inputWidth)
if i == m.focusedIndex {
inputStyle = inputStyle.BorderForeground(styles.Primary)
}
renderedInput := inputStyle.Render(field.Input.View())
sections = append(sections, renderedInput)
}
}
// ✅ PRODUCTION: Single status field
type ProviderInfo struct {
ID string
Name string
Description string
Configured bool // Single status - has required fields
}
// Status check via controller
configs := m.controller.GetLLMProviders()
provider := ProviderInfo{
Configured: configs["openai"].Configured, // Controller determines status
}
Removed: Dual Configured/Enabled status - controller handles enable/disable logic internally.
// ✅ Screen models handle ONLY content area
func (m *Model) View() string {
leftPanel := m.renderForm()
rightPanel := m.renderHelp()
// Adaptive layout decision
if m.isVerticalLayout() {
return m.renderVerticalLayout(leftPanel, rightPanel, width, height)
}
return m.renderHorizontalLayout(leftPanel, rightPanel, width, height)
}
// App.go handles complete layout structure
func (a *App) View() string {
header := a.renderHeader() // Screen-specific (logo or title)
footer := a.renderFooter() // Dynamic actions based on screen
content := a.currentModel.View() // Content only from model
contentWidth, contentHeight := a.window.GetContentSize()
contentArea := a.styles.Content.
Width(contentWidth).
Height(contentHeight).
Render(content)
return lipgloss.JoinVertical(lipgloss.Left, header, contentArea, footer)
}
// ❌ WRONG: Direct navigation creates loops
func (m *FormModel) saveAndReturn() (tea.Model, tea.Cmd) {
m.saveConfiguration()
return m, func() tea.Msg {
return NavigationMsg{Target: LLMProvidersScreen} // Loop!
}
}
// ❌ WRONG: Separate SetSelected methods
func (m *Model) SetSelectedProvider(providerID string) {
// Complexity - removed in favor of args-based construction
}
// ❌ WRONG: Fixed input widths
input.Width = 50 // Breaks responsive design
// ✅ CORRECT: GoBack navigation
func (m *FormModel) saveAndReturn() (tea.Model, tea.Cmd) {
if err := m.saveConfiguration(); err != nil {
return m, nil // Stay on form if save fails
}
return m, func() tea.Msg {
return NavigationMsg{GoBack: true} // Return to previous screen
}
}
// ✅ CORRECT: Args-based selection
func NewModel(..., args []string) *Model {
selectedIndex := 0
if len(args) > 1 && args[1] != "" {
// Set selection from args during construction
for i, item := range items {
if item.ID == args[1] {
selectedIndex = i
break
}
}
}
return &Model{selectedIndex: selectedIndex, args: args}
}
// ✅ CORRECT: Dynamic input sizing
func (m *FormModel) updateFormContent() {
inputWidth := m.getInputWidth() // Calculate based on available space
field.Input.Width = inputWidth - 3
}