Files
2026-03-26 06:16:07 +03:00

45 KiB

PentAGI Installer Documentation

Overview

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.

⚠️ Development Constraints & TUI Workflow

Core Workflow Principles

  1. Build-Only Development: NEVER run TUI apps during development - breaks terminal session
  2. Test Cycle: Build → Run separately → Return to development session
  3. Debug Output: All debug MUST go to logger.Log() (writes to log.json) - never fmt.Printf
  4. Development Monitoring: Use tail -f log.json in separate terminal

Advanced Form Patterns

Boolean Field Pattern with Suggestions

func (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()
        }
    }
}

Integer Field with Range Validation

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
        }
    }
}

Value Formatting Helpers

// 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)
}

EnvVar Integration Pattern

// 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)
    }
}

Field Cleanup Pattern

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
}

Token Estimation Pattern

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"
}

Current Configuration Display Pattern

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")
}

Enhanced Localization Pattern

// 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`
)

Type-Based Dynamic Field Generation

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:

  • Type Safety: Validation at input time
  • User Experience: Auto-completion, formatting, real-time feedback
  • Resource Awareness: Token estimation and optimization guidance
  • Environment Integration: Proper handling of defaults and cleanup
  • Maintainability: Centralized helpers and consistent patterns

Implementation Guidelines for Future Screens

Langfuse Integration Forms

Ready Patterns: Based on locale constants, implement:

  • Deployment Type Selection: Embedded/External/Disabled pattern (similar to summarizer types)
  • Conditional Fields: Show admin fields only for embedded deployment
  • Connection Testing: Validate external server connectivity
  • Environment Variables: 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)
    }
}

Observability Integration Forms

Ready Patterns: Monitoring stack configuration with similar architecture:

  • Deployment Selection: Embedded/External/Disabled (reuse pattern)
  • External Collector: OpenTelemetry endpoint configuration
  • Service Selection: Enable/disable individual monitoring components
  • Resource Estimation: Calculate monitoring overhead
// Environment variables pattern
func (m *ObservabilityFormModel) getEnvVarName(suffix string) string {
    if m.deploymentType == "external" {
        return "OTEL_" + suffix
    }
    return "OBSERVABILITY_" + suffix
}

Security Configuration

Potential Patterns: Based on established architecture:

  • Certificate Management: File path inputs with validation
  • Access Control: Boolean enable/disable with role configuration
  • Network Security: Port ranges, IP allowlists with validation
  • Encryption Settings: Key generation, algorithm selection

Enhanced Troubleshooting

AI-Powered Diagnostics:

  • System Analysis: Real-time health checks with recommendations
  • Log Analysis: Parse error logs and suggest solutions
  • Configuration Validation: Cross-check settings for conflicts
  • Interactive Fixes: Guided repair workflows

Screen Development Template

Type Selection Screen Pattern

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")
}

Form Screen Pattern

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.

New Implementation Architecture

Controller Layer Design

  • StateController: Central bridge between TUI forms and state persistence
  • Purpose: Abstracts environment variable management from UI components
  • Benefits: Type-safe configuration, automatic validation, dirty state tracking
  • Integration: All form screens use controller instead of direct state access

Adaptive Layout Strategy

  • Right Panel Hiding: Main innovation for responsive design
  • Breakpoint Logic: contentWidth < (MinMenuWidth + MinInfoWidth + 8)
  • Graceful Degradation: Information still accessible, just condensed
  • Performance: No complex re-rendering, simple layout switching

Form Architecture with Bubbles

  • textinput.Model: Used for all form inputs with consistent styling
  • Masked Input Toggle: Ctrl+H to show/hide sensitive values
  • Field Navigation: Tab/Shift+Tab for keyboard-only navigation
  • Real-time Validation: Immediate feedback and dirty state tracking
  • Provider-Specific Forms: Dynamic field generation based on provider type
  • State Persistence: Composite ScreenIDs with § separator (llm_provider_form§openai)

Composite ScreenID Architecture

  • Format: "screen" or "screen§arg1§arg2§..." for parameterized screens
  • Helper Methods: GetScreen(), GetArgs(), CreateScreenID()
  • State Persistence: Complete navigation stack with arguments preserved
  • Benefits: Type-safe parameter passing, automatic state restoration
// 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

File Organization Pattern

  • One Model Per File: welcome.go, eula.go, main_menu.go, etc.
  • Shared Constants: All in types.go for type safety
  • Locale Centralization: All user-visible text in locale/locale.go
  • Controller Separation: Business logic isolated from presentation

Architecture & Design Patterns

1. Unified App Architecture

Central Orchestrator (app.go):

  • Navigation Management: Stack-based navigation with step persistence
  • Screen Lifecycle: Model creation, initialization, and cleanup
  • Unified Layout: Header and footer rendering for all screens
  • Global Event Handling: ESC, Ctrl+C, window resize
  • Dimension Management: Terminal size distribution to models
// 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)
}

2. Navigation & State Management

Navigation Rules (Universal)

  • ESC Behavior: ALWAYS returns to Welcome screen from any screen (never nested back navigation)
  • Type Safety: Use ScreenID with CreateScreenID() for parameterized screens
  • Composite Support: Screens can carry arguments via § separator
  • State Persistence: Complete navigation stack with arguments preserved
  • EULA Consent: Check GetEulaConsent() 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 Integration

  • state.State remains authoritative for env variables
  • Controllers translate between TUI models and state operations
  • Complete state reset in Init() for predictable behavior

3. Layout & Responsive Design

Constants & Breakpoints

// 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
)

Responsive Breakpoints

  • Small screens: < 30 rows → viewport mode for scrolling
  • Large screens: ≥ 30 rows → normal layout mode
  • Narrow terminals: < 80 cols → vertical stacking
  • Wide terminals: ≥ 80 cols → horizontal panel layout

Height Control (CRITICAL)

// ❌ 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):

  • Always exactly 1 line regardless of terminal size
  • Modern appearance with background color
  • Reliable height calculations
  • Dynamic actions based on screen state
// 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)

Header Strategy

  • Welcome Screen: ASCII Art Logo (8 lines height)
  • Other Screens: Text title with consistent styling
  • Responsive: Always present, managed by app.go

5. Scrolling & Input Handling

Modern Scroll Methods

viewport.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)

Essential Key Handling

  • ↑/↓: Vertical scrolling (1 line per press)
  • ←/→: Horizontal scrolling (2 steps per press for faster navigation)
  • PgUp/PgDn: Page-level scrolling
  • Home/End: Jump to beginning/end

6. Content & Resource Management

Shared Renderer (Prevents Freezing)

// 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)
}

Content Loading Strategy

  • Single renderer instance prevents glamour freezing
  • Reset model state completely in Init() for clean transitions
  • Force view update after content loading with no-op command
  • Use embedded files via files.GetContent() - handles working directory variations

7. Component Architecture

Component Types

  1. Welcome Screen: ASCII art, system checks, info display
  2. EULA Screen: Markdown viewer with scroll-to-accept
  3. Menu Screen: Main navigation with dynamic availability
  4. Form Screens: Configuration input with validation
  5. Status Screens: Progress and result display

Key Components

  • StatusIndicator: System check results with green checkmarks/red X's
  • MarkdownViewer: EULA and help text display with scroll support
  • FormController: Bridges huh forms with state package
  • MenuList: Dynamic menu with availability checking

8. Localization & Styling

Localization Structure

wizard/locale/
└── locale.go    # All user-visible text constants

Naming Convention:

  • Welcome*, EULA*, Menu*, LLM*, Checks* - Screen-specific
  • Nav*, Status*, Error*, UI* - Functional prefixes

Styles Centralization

  • Single styles instance with shared renderer and dimensions
  • Prevents glamour freezing, centralizes terminal size management
  • All models access dimensions via m.styles.GetSize()

Development Guidelines

Screen Model Requirements

Required Implementation Pattern

// 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
}

Screen Development Checklist

For each new screen:

  • Type-safe ScreenID defined in types.go
  • State reset in Init() method with logger
  • Dimension handling via m.styles.GetSize()
  • Modern Scroll* methods for navigation
  • Arrow key handling (↑/↓/←/→) with 2-step horizontal
  • Background footer approach using locale helpers
  • Shared renderer from styles.GetRenderer()
  • ESC navigation to Welcome screen
  • Logger integration for debug output

Code Style Guidelines

Compact vs Expanded Style

// ✅ 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},
}

Comment Guidelines

  • Comments explain why and how, not what
  • Place comments where code might raise questions about business logic
  • Avoid redundant comments that repeat obvious code behavior

Recent Fixes & Improvements

Composite ScreenID Navigation System

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:

  • Type-safe parameter passing via GetScreen(), GetArgs(), CreateScreenID()
  • Automatic state restoration - user returns to exact selection after ESC
  • Clean navigation stack with full context preservation
  • Extensible for multiple arguments per screen

Complete Localization Architecture

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

  • Menu descriptions and help text
  • Form labels and error messages
  • Provider-specific documentation
  • Keyboard shortcuts and hints

Viewport-Based Form Scrolling

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:

  • Auto-scroll: Focused field automatically stays visible
  • Smart positioning: Calculates field heights for precise scroll positioning
  • Seamless navigation: Tab/Shift+Tab scroll form as needed
  • No extra hotkeys: Uses existing navigation keys

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
    }
}

Enhanced Provider Configuration

Problem: Missing configuration fields for several LLM providers Solution: Added complete field sets for all supported providers

Provider Field Mapping:

  • OpenAI/Anthropic/Gemini: Base URL + API Key
  • AWS Bedrock: Region + Authentication (Default Auth OR Bearer Token OR Access Key + Secret Key) + Session Token (optional) + Base URL (optional)
  • DeepSeek/GLM/Kimi/Qwen: Base URL + API Key + Provider Name (optional, for LiteLLM)
  • Ollama: Base URL + API Key (optional, for cloud) + Model + Config Path + Pull/Load options
  • Custom: Base URL + API Key + Model + Config Path + Provider Name + Legacy Reasoning (boolean)

Dynamic Form Generation: Forms adapt based on provider type with appropriate validation and help text.

Error Handling & Performance

Error Handling Strategy

Graceful degradation with user-friendly messages:

  1. System check failures: Show specific resolution steps
  2. Form validation: Real-time feedback with clear messaging
  3. State persistence errors: Allow retry with explanation
  4. Network issues: Offer offline/manual alternatives

Performance Considerations

Lazy loading approach:

  • System checks run asynchronously after welcome screen loads
  • Markdown content loaded on-demand when screens are accessed
  • Form validation debounced to avoid excessive state updates

Common Pitfalls & Solutions

Content Loading Issues

Problem: "Loading EULA" state persists, content doesn't appear Solutions:

  1. Multiple Path Fallback: Try embedded FS first, then direct file access
  2. State Reset: Always reset model state in Init() for clean loading
  3. No ClearScreen: Avoid tea.ClearScreen during navigation
  4. Force View Update: Return no-op command after content loading

Layout Consistency Issues

Problem: 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

Common Mistakes to Avoid

  • Using tea.ClearScreen in navigation
  • Border-based footer (height inconsistency)
  • String-based navigation messages
  • Creating new glamour renderer instances
  • Forgetting state reset in Init()
  • Using fmt.Printf for debug output
  • Deprecated Line* scroll methods

Technology Stack

  • bubbletea: Core TUI framework using Model-View-Update pattern
  • lipgloss: Styling and layout engine for visual presentation
  • bubbles: Component library for interactive elements (list, textinput, viewport)
  • huh: Form builder for structured input collection (future screens)
  • glamour: Markdown rendering with single shared instance
  • logger: Custom file-based logging for TUI-safe development

Production Architecture Implementation

Completed Form System Architecture

Form Model Pattern (llm_provider_form.go)

type 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:

  • Args-based Construction: NewLLMProviderFormModel(controller, styles, window, args)
  • Permanent Viewport: Form viewport as struct property to preserve scroll state
  • Auto-completion: Tab key triggers suggestion completion for boolean fields
  • GoBack Navigation: return NavigationMsg{GoBack: true} prevents navigation loops

Navigation Hotkeys (Production Pattern)

// 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.

Adaptive Layout System

Layout Constants (Production Values)

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
)

Two-Column Layout Implementation

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()
}

Content Hiding Strategy

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
}

Composite ScreenID Navigation System

ScreenID Argument Packaging

// 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

Args-Based Model Construction

// 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
}

Navigation Stack Management

Stack Example: ["main_menu§llm_providers", "llm_providers§openai", "llm_provider_form§openai"]

  • Forward Navigation: Pushes composite ScreenID with arguments
  • Back Navigation: GoBack: true pops current screen, returns to previous with preserved selection
  • No Navigation Loops: GoBack pattern prevents infinite stack growth

Viewport Usage Patterns

Forms: Permanent Viewport Property

// ✅ 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
    }
}

Layout: Temporary Viewport Creation

// ✅ 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()
}

Dynamic Form Field Architecture

Field Configuration Pattern

// 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"})
    }
}

Dynamic Width Calculation

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)
    }
}

Provider Configuration Architecture

Simplified Status Model

// ✅ 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.

Provider-Specific Field Sets

  • OpenAI/Anthropic/Gemini: Base URL + API Key
  • AWS Bedrock: Region + Authentication (Default Auth OR Bearer Token OR Access Key + Secret Key) + Session Token (optional) + Base URL (optional)
    • Default Auth: Use AWS SDK credential chain (environment, EC2 role, ~/.aws/credentials) - highest priority
    • Bearer Token: Token-based authentication - priority over static credentials
    • Static Credentials: Access Key + Secret Key + Session Token (optional) - traditional IAM authentication
  • DeepSeek/GLM/Kimi/Qwen: Base URL + API Key + Provider Name (optional, for LiteLLM)
  • Ollama: Base URL + API Key (optional, for cloud) + Model + Config Path + Pull/Load options
  • Custom: Base URL + API Key + Model + Config Path + Provider Name + Legacy/Preserve Reasoning (boolean with suggestions)

Screen Architecture (App.go Integration)

Content Area Responsibility

// ✅ 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 Layout Management

// 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)
}

Navigation Anti-Patterns & Solutions

Common Mistakes

// ❌ 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 Patterns

// ✅ 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
}