Skip to main content

Overview

SSH Portfolio is built with Go using three main libraries:
  • Wish - SSH server framework
  • Bubble Tea - Terminal UI framework with The Elm Architecture
  • Lipgloss - Terminal styling and layout

Core Components

1. SSH Server (Wish)

The application uses Charm’s Wish library to create an SSH server that serves interactive terminal UIs.
main.go:36-44
s, err := wish.NewServer(
    wish.WithAddress(net.JoinHostPort(host, port)),
    wish.WithHostKeyPath(".ssh/id_ed25519"),
    wish.WithMiddleware(
        bm.Middleware(teaHandler(cfg)),
        activeterm.Middleware(),
        logging.Middleware(),
    ),
)
Server Configuration:
  • Listens on 0.0.0.0:2222 by default (configurable via SSH_PORT env var)
  • Uses Ed25519 host key from .ssh/id_ed25519
  • Runs with graceful shutdown handling (30s timeout)

2. Middleware Stack

The server uses three middleware layers in order:
MiddlewarePurposeSource
bubbletea.MiddlewareRuns Bubble Tea programs for each SSH sessionmain.go:40
activeterm.MiddlewareFilters out non-interactive terminalsmain.go:41
logging.MiddlewareLogs SSH connection eventsmain.go:42

3. Configuration Loader

Loads portfolio data from config.yaml on startup:
main.go:31-34
cfg, err := config.Load("config.yaml")
if err != nil {
    log.Fatalf("Failed to load config: %v", err)
}
See Configuration Schema for complete config structure.

4. TUI Framework (Bubble Tea)

Each SSH session runs an independent Bubble Tea program with The Elm Architecture:
Model → Update → View
  ↑                 |
  └─────────────────┘

Model Initialization

The teaHandler function creates a new model for each SSH connection:
main.go:70-77
func teaHandler(cfg *config.Config) func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
    return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
        pty, _, _ := s.Pty()
        renderer := bm.MakeRenderer(s)
        m := ui.NewModel(cfg, renderer, pty.Window.Width, pty.Window.Height)
        return m, []tea.ProgramOption{tea.WithAltScreen()}
    }
}
Key steps:
  1. Extract PTY dimensions from SSH session
  2. Create a Lipgloss renderer for the session
  3. Initialize the UI model with config and dimensions
  4. Enable alternate screen buffer (restores terminal on exit)

Message Flow

Update Cycle

Bubble Tea processes messages through the Update method:
ui/model.go:59-97
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height
        return m, nil

    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "left", "h":
            if m.activePage > PageHome {
                m.activePage--
            }
            return m, nil
        case "right", "l":
            if m.activePage < PageContact {
                m.activePage++
            }
            return m, nil
        }
    }

    // Delegate to active page
    var cmd tea.Cmd
    cw, ch := m.contentWidth(), m.contentHeight()
    switch m.activePage {
    case PageHome:
        m.home, cmd = m.home.Update(msg, cw, ch)
    case PageSkills:
        m.skills, cmd = m.skills.Update(msg, cw, ch)
    case PageProjects:
        m.projects, cmd = m.projects.Update(msg, cw, ch)
    case PageContact:
        m.contact, cmd = m.contact.Update(msg, cw, ch)
    }
    return m, cmd
}
Message handling priority:
  1. Window resize events (updates dimensions)
  2. Global keyboard shortcuts (q, arrows for navigation)
  3. Delegation to active page model for page-specific input

Render Cycle

The View method renders the UI in three parts:
ui/model.go:99-127
func (m Model) View() string {
    tabBar := m.renderTabBar()
    statusBar := m.renderStatusBar()

    cw, ch := m.contentWidth(), m.contentHeight()
    var content string
    switch m.activePage {
    case PageHome:
        content = m.home.View(cw, ch, m.renderer)
    case PageSkills:
        content = m.skills.View(cw, ch, m.renderer)
    case PageProjects:
        content = m.projects.View(cw, ch, m.renderer)
    case PageContact:
        content = m.contact.View(cw, ch, m.renderer)
    }

    contentStyle := m.renderer.NewStyle().
        Width(m.width).
        Height(ch)
    content = contentStyle.Render(content)

    return lipgloss.JoinVertical(lipgloss.Left, tabBar, content, statusBar)
}
Layout structure:
┌────────────────────────────┐
│ Tab Bar (3 lines)          │
├────────────────────────────┤
│                            │
│ Page Content               │
│ (dynamic height)           │
│                            │
├────────────────────────────┤
│ Status Bar (1 line)        │
└────────────────────────────┘

Directory Structure

ssh-portfolio/
├── main.go              # Server setup and teaHandler
├── config.yaml          # Portfolio content
├── config/
│   └── config.go        # Config schema and loader
└── ui/
    ├── model.go         # Main model and page routing
    ├── home.go          # Home page model
    ├── skills.go        # Skills page with cursor
    ├── projects.go      # Projects page with cursor
    ├── contact.go       # Contact page
    └── theme.go         # Catppuccin color constants

Package Responsibilities

PackagePurposeKey Files
mainServer initialization, middleware setupmain.go
configConfiguration loading and schemaconfig/config.go
uiAll UI components and rendering logicui/*.go

Page Models

Each page implements its own model with Update and View methods:
type HomeModel struct { cfg *config.Config }
type SkillsModel struct { cfg *config.Config; cursor int }
type ProjectsModel struct { cfg *config.Config; cursor int }
type ContactModel struct { cfg *config.Config }
Pages with lists (Skills, Projects) maintain a cursor field for selection state. See UI Components for detailed component reference.

Styling System

All styling uses Lipgloss with Catppuccin Mocha theme:
ui/theme.go
const (
    Blue      = "#89b4fa"
    Mauve     = "#cba6f7"
    Green     = "#a6e3a1"
    Peach     = "#fab387"
    // ... 26 colors total
)
Each component creates styles via the shared *lipgloss.Renderer passed from the session.

Key Design Patterns

The Elm Architecture

All UI logic follows TEA principles:
  • Model - Application state (immutable)
  • Update - State transitions based on messages
  • View - Pure rendering function from state

Session Isolation

Each SSH connection gets:
  • Independent Bubble Tea program instance
  • Separate renderer for terminal capabilities
  • Own copy of the config data
  • Isolated state (cursor positions, active page)

Layout Calculation

Available content space is calculated dynamically:
ui/model.go:129-139
func (m Model) contentWidth() int {
    return m.width
}

func (m Model) contentHeight() int {
    h := m.height - tabBarHeight - statusBarHeight
    if h < 1 {
        return 1
    }
    return h
}
Constants defined: tabBarHeight = 3, statusBarHeight = 1

Build docs developers (and LLMs) love