feat: Stage 1 scaffold — config, API client, placeholder TUI

Sets up the full project structure following the hactl/gokapi-tui
architecture: strict Elm-pattern Bubbletea TUI, YAML config at
~/.config/terdut-tui/config.yaml, REST API client with Bearer auth,
self-update via GitHub Releases, and a three-section tab placeholder
(Alerts / Schedule / Users) that verifies server connectivity on startup.
This commit is contained in:
Niklas Ye
2026-05-21 13:28:13 +02:00
commit 55ee64530b
14 changed files with 849 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
package tui
import "github.com/charmbracelet/bubbles/key"
type keyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Tab key.Binding
Enter key.Binding
Escape key.Binding
Refresh key.Binding
Filter key.Binding
Quit key.Binding
}
var keys = keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("◀/h", "prev")),
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("▶/l", "next")),
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch section")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.Down, k.Tab, k.Refresh, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Left, k.Right},
{k.Tab, k.Enter, k.Escape},
{k.Refresh, k.Filter, k.Quit},
}
}
+89
View File
@@ -0,0 +1,89 @@
package tui
import (
"time"
"github.com/charmbracelet/bubbles/help"
tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
)
type section int
const (
sectionAlerts section = iota
sectionSchedule
sectionUsers
)
type mode int
const (
modeDashboard mode = iota
modeDetail
modeSchedule
modeUsers
)
// tea.Msg types
type connectedMsg struct{ serverURL string }
type connectErrMsg struct{ err error }
type tickMsg time.Time
type clearStatusMsg struct{}
// Model holds all UI state.
type Model struct {
client *api.Client
serverURL string
refreshInterval time.Duration
activeSection section
mode mode
width int
height int
connected bool
err error
statusMsg string
help help.Model
keys keyMap
}
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model {
return Model{
client: client,
serverURL: serverURL,
refreshInterval: refreshInterval,
activeSection: sectionAlerts,
mode: modeDashboard,
help: help.New(),
keys: keys,
}
}
func (m Model) Init() tea.Cmd {
return connectCmd(m.client)
}
func connectCmd(client *api.Client) tea.Cmd {
return func() tea.Msg {
if err := client.HealthCheck(); err != nil {
return connectErrMsg{err}
}
return connectedMsg{}
}
}
func tickCmd(interval time.Duration) tea.Cmd {
return tea.Tick(interval, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func clearStatusCmd() tea.Cmd {
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
return clearStatusMsg{}
})
}
+41
View File
@@ -0,0 +1,41 @@
package tui
import "github.com/charmbracelet/lipgloss"
var (
colorPrimary = lipgloss.Color("69") // blue
colorMuted = lipgloss.Color("240") // gray
colorFiring = lipgloss.Color("196") // red
colorResolved = lipgloss.Color("70") // green
colorAccent = lipgloss.Color("214") // orange
styleHeader = lipgloss.NewStyle().
Bold(true).
Foreground(colorPrimary).
Padding(0, 1)
styleTabActive = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Padding(0, 2)
styleTabInactive = lipgloss.NewStyle().
Foreground(colorMuted).
Padding(0, 2)
styleFooter = lipgloss.NewStyle().
Foreground(colorMuted)
styleStatus = lipgloss.NewStyle().
Foreground(colorAccent).
Bold(true)
styleError = lipgloss.NewStyle().
Foreground(colorFiring).
Bold(true)
styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true)
styleResolved = lipgloss.NewStyle().Foreground(colorResolved)
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
)
+54
View File
@@ -0,0 +1,54 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
)
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 connectedMsg:
m.connected = true
m.err = nil
m.statusMsg = "Connected to " + m.serverURL
return m, tea.Batch(tickCmd(m.refreshInterval), clearStatusCmd())
case connectErrMsg:
m.connected = false
m.err = msg.err
return m, nil
case tickMsg:
return m, tickCmd(m.refreshInterval)
case clearStatusMsg:
m.statusMsg = ""
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch {
case msg.String() == "q" || msg.String() == "ctrl+c":
return m, tea.Quit
case msg.String() == "tab":
m.activeSection = (m.activeSection + 1) % 3
return m, nil
case msg.String() == "r":
m.statusMsg = "Refreshing…"
return m, tea.Batch(connectCmd(m.client), clearStatusCmd())
}
return m, nil
}
+87
View File
@@ -0,0 +1,87 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
var sectionNames = []string{"Alerts", "Schedule", "Users"}
func (m Model) View() string {
if m.width == 0 {
return ""
}
header := m.renderHeader()
tabs := m.renderTabs()
body := m.renderBody()
footer := m.renderFooter()
return lipgloss.JoinVertical(lipgloss.Left, header, tabs, body, footer)
}
func (m Model) renderHeader() string {
title := styleHeader.Render("terdut-tui")
right := ""
if m.serverURL != "" {
right = styleMuted.Render(m.serverURL)
}
gap := m.width - lipgloss.Width(title) - lipgloss.Width(right)
if gap < 0 {
gap = 0
}
return title + strings.Repeat(" ", gap) + right
}
func (m Model) renderTabs() string {
var tabs []string
for i, name := range sectionNames {
if section(i) == m.activeSection {
tabs = append(tabs, styleTabActive.Render(name))
} else {
tabs = append(tabs, styleTabInactive.Render(name))
}
}
line := strings.Repeat("─", m.width)
return strings.Join(tabs, "") + "\n" + styleMuted.Render(line)
}
func (m Model) renderBody() string {
bodyHeight := m.height - 5 // header + tabs + separator + footer + help
if bodyHeight < 1 {
bodyHeight = 1
}
var content string
if m.err != nil {
content = styleError.Render(fmt.Sprintf("Error: %v", m.err))
} else if !m.connected {
content = styleMuted.Render("Connecting…")
} else if m.statusMsg != "" {
content = styleStatus.Render(m.statusMsg)
} else {
switch m.activeSection {
case sectionAlerts:
content = styleMuted.Render("Alert dashboard — coming in Stage 2")
case sectionSchedule:
content = styleMuted.Render("On-call schedule — coming in Stage 4")
case sectionUsers:
content = styleMuted.Render("User management — coming in Stage 5")
}
}
lines := strings.Split(content, "\n")
padding := (bodyHeight - len(lines)) / 2
if padding < 0 {
padding = 0
}
return strings.Repeat("\n", padding) + content
}
func (m Model) renderFooter() string {
helpView := m.help.ShortHelpView(m.keys.ShortHelp())
return "\n" + styleFooter.Render(helpView)
}