feat(tui): add Dracula lipgloss theme with auto-detection

- Define Theme struct with all Dracula palette colors
- Auto-detect terminal color support (truecolor, 256-color, no-color)
- Implement graceful fallback for dumb terminals
- Fix model theme field type to use pointer
- Colors: stopped badge (#F1FA8C), permission badge (#FF5555), selected row (#BD93F9),
  panel border (#6272A4), header bar (#282A36), status bar (#44475A),
  metadata text (#AAAAAA), accent cyan (#8BE9FD), accent green (#50FA7B)

Closes tb-6cj
This commit is contained in:
jedarden 2026-06-25 00:50:59 -04:00
parent 1f0e7d4f95
commit dd0ebbd6ce
2 changed files with 1047 additions and 0 deletions

732
tui/model.go Normal file
View file

@ -0,0 +1,732 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ---------------------------------------------------------------------------
// Tea message types
// ---------------------------------------------------------------------------
type tickMsg time.Time
type queueMsg []QueueItem
type statusMsg StatusResponse
type errMsg struct{ err error }
type jumpDoneMsg struct{}
type paneMapMsg map[string]string
func (e errMsg) Error() string { return e.err.Error() }
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
// Model is the main Bubble Tea model for the Trail Boss TUI.
type Model struct {
queue []QueueItem // Changed from QueueResponse to []QueueItem
status StatusResponse
paneMap map[string]string // pane_id → session_name
cursor int
detailScroll int
width int
height int
daemonOK bool
err error
lastKey string
showHelp bool
loading bool
theme *Theme
detail viewport.Model
}
// NewModel creates a fully-initialized Model.
func NewModel() Model {
return Model{
theme: NewTheme(),
paneMap: map[string]string{},
loading: true,
}
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
func (m Model) Init() tea.Cmd {
return tea.Batch(
fetchQueueCmd(),
fetchStatusCmd(),
fetchPaneMapCmd(),
tickCmd(),
)
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
func tickCmd() tea.Cmd {
return tea.Tick(3*time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func fetchQueueCmd() tea.Cmd {
return func() tea.Msg {
q, err := FetchQueue()
if err != nil {
return errMsg{err}
}
return queueMsg(q)
}
}
func fetchStatusCmd() tea.Cmd {
return func() tea.Msg {
s, err := FetchStatus()
if err != nil {
return errMsg{err}
}
return statusMsg(s)
}
}
func fetchPaneMapCmd() tea.Cmd {
return func() tea.Msg {
return paneMapMsg(GetPaneSessionMap())
}
}
func skipCmd() tea.Cmd {
return func() tea.Msg {
_ = PostSkip()
q, err := FetchQueue()
if err != nil {
return errMsg{err}
}
return queueMsg(q)
}
}
func jumpToPaneCmd(paneID string) tea.Cmd {
return func() tea.Msg {
_ = JumpToPane(paneID)
return jumpDoneMsg{}
}
}
func jumpToNextCmd() tea.Cmd {
return func() tea.Msg {
paneID, err := PostNext()
if err != nil || paneID == "" {
return jumpDoneMsg{}
}
_ = JumpToPane(paneID)
return jumpDoneMsg{}
}
}
// ---------------------------------------------------------------------------
// Update
// ---------------------------------------------------------------------------
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
m.detail = viewport.New(m.detailWidth(), m.contentHeight())
m.detail.SetContent(m.detailContent())
return m, nil
case tickMsg:
return m, tea.Batch(fetchQueueCmd(), fetchStatusCmd(), fetchPaneMapCmd(), tickCmd())
case queueMsg:
m.loading = false
m.daemonOK = true
m.err = nil
oldLen := len(m.queue)
m.queue = msg
// Clamp cursor.
if m.cursor >= len(m.queue) {
m.cursor = max(0, len(m.queue)-1)
}
// Reset detail scroll only when the list shrank or was empty.
if len(m.queue) < oldLen || oldLen == 0 {
m.detailScroll = 0
}
m.detail.SetContent(m.detailContent())
m.syncPreview()
return m, nil
case statusMsg:
m.status = StatusResponse(msg)
return m, nil
case errMsg:
m.loading = false
m.daemonOK = false
m.err = msg.err
return m, nil
case paneMapMsg:
m.paneMap = map[string]string(msg)
return m, nil
case jumpDoneMsg:
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
key := msg.String()
if m.showHelp {
if key == "?" || key == "q" || key == "esc" {
m.showHelp = false
}
return m, nil
}
n := len(m.queue)
// gg — jump to top
if key == "g" {
if m.lastKey == "g" {
m.cursor = 0
m.detailScroll = 0
m.lastKey = ""
m.detail.SetContent(m.detailContent())
m.syncPreview()
return m, nil
}
m.lastKey = "g"
return m, nil
}
m.lastKey = key
switch key {
case "q", "ctrl+c":
return m, tea.Quit
case "?":
m.showHelp = true
case "j", "down":
if m.cursor < n-1 {
m.cursor++
m.detailScroll = 0
m.detail.SetContent(m.detailContent())
m.syncPreview()
}
case "k", "up":
if m.cursor > 0 {
m.cursor--
m.detailScroll = 0
m.detail.SetContent(m.detailContent())
m.syncPreview()
}
case "G":
if n > 0 {
m.cursor = n - 1
m.detailScroll = 0
m.detail.SetContent(m.detailContent())
m.syncPreview()
}
case "ctrl+d":
half := max(1, m.contentHeight()/2)
m.cursor = min(n-1, m.cursor+half)
m.detailScroll = 0
m.detail.SetContent(m.detailContent())
m.syncPreview()
case "ctrl+u":
half := max(1, m.contentHeight()/2)
m.cursor = max(0, m.cursor-half)
m.detailScroll = 0
m.detail.SetContent(m.detailContent())
m.syncPreview()
case "enter", "l":
if n > 0 && m.cursor < n {
return m, jumpToPaneCmd(m.queue[m.cursor].PaneID)
}
case "tab":
return m, jumpToNextCmd()
case "s":
return m, skipCmd()
case "r":
return m, tea.Batch(fetchQueueCmd(), fetchStatusCmd(), fetchPaneMapCmd())
case "J":
m.detail.LineDown(1)
case "K":
m.detail.LineUp(1)
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
idx := int(key[0]-'0') - 1
if idx >= 0 && idx < n {
m.cursor = idx
m.detailScroll = 0
m.detail.SetContent(m.detailContent())
m.syncPreview()
}
}
return m, nil
}
// ---------------------------------------------------------------------------
// View
// ---------------------------------------------------------------------------
func (m Model) View() string {
if m.width == 0 || m.height == 0 {
return "Loading..."
}
if m.showHelp {
return m.helpView()
}
header := m.headerView()
status := m.statusBarView()
// Reserve 2 rows for header + status bar.
bodyHeight := m.height - 2
var body string
if m.width >= 100 {
body = m.splitView(bodyHeight)
} else {
body = m.listOnlyView(bodyHeight)
}
return lipgloss.JoinVertical(lipgloss.Left, header, body, status)
}
// headerView renders the top bar.
func (m Model) headerView() string {
var countBadge string
if len(m.queue) == 0 {
countBadge = m.theme.AccentGreen.Render(fmt.Sprintf("✓ %d stuck", len(m.queue)))
} else {
countBadge = m.theme.AccentCyan.Render(fmt.Sprintf("⚠ %d stuck", len(m.queue)))
}
keys := m.theme.MetaText.Render(" [Tab] next [s] skip [Enter] jump [?] help [q] quit")
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#BD93F9")).Render("Trail Boss")
content := title + " " + countBadge + keys
return m.theme.HeaderBar.Width(m.width).Render(content)
}
// statusBarView renders the bottom status bar.
func (m Model) statusBarView() string {
var parts []string
if m.daemonOK {
parts = append(parts, m.theme.AccentGreen.Render("daemon: ok ✓"))
} else {
parts = append(parts, lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555")).Bold(true).Render("daemon: unreachable ✗"))
}
parts = append(parts, fmt.Sprintf("queue: %d", len(m.queue)))
if m.daemonOK {
ago := m.status.LastReconcileAgoS
if ago < 0 {
ago = 0
}
parts = append(parts, fmt.Sprintf("reconcile: %.0fs ago", ago))
if m.status.SkipCooldownS > 0 {
parts = append(parts, fmt.Sprintf("skip cooldown: %.0fs", m.status.SkipCooldownS))
} else {
parts = append(parts, "skip cooldown: —")
}
}
content := strings.Join(parts, " ")
return m.theme.StatusBar.Width(m.width).Render(content)
}
// splitView renders the two-pane layout.
func (m Model) splitView(height int) string {
listW := m.listWidth()
detailW := m.detailWidth()
listContent := m.listView(listW, height)
detailContent := m.detailPaneView(detailW, height)
// Pad list to full height.
listLines := strings.Split(listContent, "\n")
for len(listLines) < height {
listLines = append(listLines, strings.Repeat(" ", listW))
}
listContent = strings.Join(listLines, "\n")
// Draw vertical divider.
divider := lipgloss.NewStyle().
Foreground(m.theme.BorderColor).
Render(strings.Repeat("│\n", height-1)+"│")
return lipgloss.JoinHorizontal(lipgloss.Top, listContent, divider, detailContent)
}
// listOnlyView renders the list filling the full width.
func (m Model) listOnlyView(height int) string {
return m.listView(m.width, height)
}
// listWidth returns the width for the list pane.
func (m Model) listWidth() int {
w := m.width * 40 / 100
if w < 30 {
w = 30
}
return w
}
// detailWidth returns the width for the detail pane.
func (m Model) detailWidth() int {
// Subtract list width and the 1-char divider.
return m.width - m.listWidth() - 1
}
// contentHeight returns body height (total minus header/status rows).
func (m Model) contentHeight() int {
h := m.height - 2
if h < 1 {
h = 1
}
return h
}
// listView renders the queue list pane.
func (m Model) listView(width, height int) string {
t := m.theme
// Header row.
colIdx := padRight(" #", 3)
colReason := padRight("REASON", 12)
colSession := "SESSION"
headerText := t.MetaText.Render(colIdx + " " + colReason + " " + colSession)
sep := t.MetaText.Render(strings.Repeat("─", width))
lines := []string{headerText, sep}
if len(m.queue) == 0 {
emptyMsg := t.MetaText.Render(" (no stuck sessions)")
lines = append(lines, emptyMsg)
}
for i, item := range m.queue.Items {
// Determine session label.
sessLabel := m.paneMap[item.PaneID]
if sessLabel == "" {
// Fall back to last path component of CWD.
parts := strings.Split(strings.TrimRight(item.CWD, "/"), "/")
if len(parts) > 0 {
sessLabel = parts[len(parts)-1]
}
}
if sessLabel == "" && len(item.SessionID) > 8 {
sessLabel = item.SessionID[:8]
} else if sessLabel == "" {
sessLabel = item.SessionID
}
// Reason badge.
var badge string
switch item.Reason {
case "permission":
badge = t.PermissionBadge.Render("perm")
default:
badge = t.StoppedBadge.Render("stop")
}
// Marker for selected row.
marker := " "
if i == m.cursor {
marker = "▶"
}
idxStr := fmt.Sprintf("%2d", i+1)
// Plain text portion of the row: idx + badge + session.
// We can't easily mix styled badge and row highlight, so render differently.
plain := fmt.Sprintf("%s %s %-14s %s", idxStr, badge, padRight(sessLabel, 14), marker)
var line string
if i == m.cursor {
line = t.SelectedRow.Width(width).Render(plain)
} else {
line = t.NormalRow.Width(width).Render(plain)
}
lines = append(lines, line)
}
// Pad to height.
for len(lines) < height {
lines = append(lines, strings.Repeat(" ", width))
}
// Truncate if too tall.
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
// detailPaneView renders the right detail panel.
func (m Model) detailPaneView(width, height int) string {
t := m.theme
if len(m.queue) == 0 || m.cursor >= len(m.queue) {
empty := t.MetaText.Render(" (select an item to view details)")
return padToHeight(empty, width, height)
}
item := m.queue[m.cursor]
// Resolve session.
sessLabel := m.paneMap[item.PaneID]
if sessLabel == "" {
parts := strings.Split(strings.TrimRight(item.CWD, "/"), "/")
if len(parts) > 0 {
sessLabel = parts[len(parts)-1]
}
}
// Compute relative stuck time.
stuckAge := ""
if item.StuckAt > 0 {
d := time.Since(time.UnixMilli(item.StuckAt))
stuckAge = formatDuration(d)
}
// Build detail title.
titleText := fmt.Sprintf("Detail: %s", sessLabel)
if stuckAge != "" {
titleText += fmt.Sprintf(" — %s", stuckAge)
}
title := t.AccentCyan.Render(titleText)
sep := t.MetaText.Render(strings.Repeat("─", width-2))
// CWD line.
cwdLine := t.MetaText.Render("cwd: ") + t.NormalRow.Render(item.CWD)
// Last message, wrapped.
msgLabel := t.MetaText.Render("last message:")
innerWidth := width - 4 // 2 for padding each side
if innerWidth < 10 {
innerWidth = 10
}
wrapped := wordWrap(item.LastMessage, innerWidth)
// Update the viewport with the latest content, then render it.
m.detail.Width = width
m.detail.Height = height - 4 // subtract title+sep+cwd+label rows
if m.detail.Height < 1 {
m.detail.Height = 1
}
fullContent := lipgloss.JoinVertical(lipgloss.Left,
title,
sep,
cwdLine,
msgLabel,
" "+strings.ReplaceAll(wrapped, "\n", "\n "),
)
_ = fullContent // set in Update, not here
// Compose visible lines.
var lines []string
lines = append(lines, title)
lines = append(lines, sep)
lines = append(lines, cwdLine)
lines = append(lines, msgLabel)
msgLines := strings.Split(" "+strings.ReplaceAll(strings.TrimSpace(wrapped), "\n", "\n "), "\n")
lines = append(lines, msgLines...)
// Apply scroll offset.
if m.detail.YOffset > 0 && m.detail.YOffset < len(lines) {
lines = lines[m.detail.YOffset:]
}
// Pad / truncate to height.
for len(lines) < height {
lines = append(lines, "")
}
if len(lines) > height {
lines = lines[:height]
}
return strings.Join(lines, "\n")
}
// detailContent returns the text to put in the viewport for the current item.
func (m Model) detailContent() string {
if len(m.queue) == 0 || m.cursor >= len(m.queue) {
return "(no item selected)"
}
item := m.queue[m.cursor]
innerWidth := m.detailWidth() - 4
if innerWidth < 10 {
innerWidth = 10
}
wrapped := wordWrap(item.LastMessage, innerWidth)
return wrapped
}
// helpView renders the keyboard shortcut overlay.
func (m Model) helpView() string {
help := `
Trail Boss Keyboard Shortcuts
j / Move cursor down
k / Move cursor up
g g Jump to top
G Jump to bottom
ctrl+d Page down (half)
ctrl+u Page up (half)
19 Jump directly to item N
Enter / l Jump to cursor's pane
Tab Jump to queue head
s Skip queue head
r Force refresh
J Scroll detail pane down
K Scroll detail pane up
? Toggle this help
q / ctrl+c Quit
Press ? or q to close
`
boxStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(m.theme.BorderColor).
Padding(1, 2)
box := boxStyle.Render(strings.TrimLeft(help, "\n"))
// Center in terminal.
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box)
}
// syncPreview writes the currently-selected pane_id to the preview target file
// so the trailboss-preview pane below mirrors the right session.
func (m Model) syncPreview() {
if len(m.queue) > 0 && m.cursor < len(m.queue) {
WritePreviewTarget(m.queue[m.cursor].PaneID)
} else {
WritePreviewTarget("")
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func padRight(s string, n int) string {
if len(s) >= n {
return s[:n]
}
return s + strings.Repeat(" ", n-len(s))
}
func padToHeight(content string, width, height int) string {
lines := strings.Split(content, "\n")
for len(lines) < height {
lines = append(lines, strings.Repeat(" ", width))
}
return strings.Join(lines[:height], "\n")
}
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
if d < 0 {
d = 0
}
total := int(d.Seconds())
h := total / 3600
m := (total % 3600) / 60
s := total % 60
if h > 0 {
return fmt.Sprintf("%dh %dm %ds", h, m, s)
}
if m > 0 {
return fmt.Sprintf("%dm %ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
// wordWrap wraps text at word boundaries to fit within the given width.
func wordWrap(text string, width int) string {
if width <= 0 {
return text
}
var result strings.Builder
for _, paragraph := range strings.Split(text, "\n") {
words := strings.Fields(paragraph)
if len(words) == 0 {
result.WriteString("\n")
continue
}
lineLen := 0
for i, word := range words {
wl := len(word)
if i == 0 {
result.WriteString(word)
lineLen = wl
} else if lineLen+1+wl > width {
result.WriteString("\n")
result.WriteString(word)
lineLen = wl
} else {
result.WriteString(" ")
result.WriteString(word)
lineLen += 1 + wl
}
}
result.WriteString("\n")
}
return strings.TrimRight(result.String(), "\n")
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

315
tui/theme.go Normal file
View file

@ -0,0 +1,315 @@
package main
import (
"os"
"github.com/charmbracelet/lipgloss"
)
// Theme defines all visual styles for the Trail Boss TUI using the Dracula color palette.
type Theme struct {
// Badge styles
StoppedBadge lipgloss.Style
PermissionBadge lipgloss.Style
// Row styles
SelectedRow lipgloss.Style
NormalRow lipgloss.Style
// Panel styles
PanelBorder lipgloss.Style
PanelHeader lipgloss.Style
DetailPanel lipgloss.Style
// Bar styles
HeaderBar lipgloss.Style
StatusBar lipgloss.Style
// Text styles
MetaText lipgloss.Style
AccentCyan lipgloss.Style
AccentGreen lipgloss.Style
// Color support detection
hasTrueColor bool
has256Color bool
noColor bool
}
// NewTheme creates a new Theme with auto-detected terminal color support.
func NewTheme() *Theme {
t := &Theme{}
t.detectColorSupport()
t.applyColors()
return t
}
// detectColorSupport determines the terminal's color capabilities.
func (t *Theme) detectColorSupport() {
// Check NO_COLOR environment variable (https://no-color.org/)
if _, exists := os.LookupEnv("NO_COLOR"); exists {
t.noColor = true
return
}
// Check TERM environment variable
term := os.Getenv("TERM")
// Truecolor support
if os.Getenv("COLORTERM") == "truecolor" || os.Getenv("COLORTERM") == "24bit" {
t.hasTrueColor = true
t.has256Color = true
return
}
// 256 color support
if term == "xterm-256color" || term == "screen-256color" || term == "tmux-256color" {
t.has256Color = true
return
}
// Dumb terminal
if term == "dumb" || term == "" {
t.noColor = true
return
}
}
// applyColors configures all styles based on detected color support.
func (t *Theme) applyColors() {
if t.noColor {
t.applyNoColor()
return
}
if t.hasTrueColor {
t.applyTrueColor()
return
}
if t.has256Color {
t.apply256Color()
return
}
t.applyBasicColor()
}
// applyTrueColor applies full RGB colors (Dracula palette).
func (t *Theme) applyTrueColor() {
// Badge styles - yellow/red foreground on dark background
t.StoppedBadge = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F1FA8C")). // Yellow
Background(lipgloss.Color("#282A36")). // Dark background
Bold(true).
Padding(0, 1).
MarginRight(1)
t.PermissionBadge = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5555")). // Red
Background(lipgloss.Color("#282A36")). // Dark background
Bold(true).
Padding(0, 1).
MarginRight(1)
// Row styles
t.SelectedRow = lipgloss.NewStyle().
Background(lipgloss.Color("#BD93F9")). // Purple
Foreground(lipgloss.Color("#282A36")). // Dark text for contrast
Bold(true)
t.NormalRow = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F8F8F2")) // Dracula foreground
// Panel styles - rounded border
t.PanelBorder = lipgloss.NewStyle().
Foreground(lipgloss.Color("#6272A4")). // Blue-ish
Border(lipgloss.RoundedBorder())
t.PanelHeader = lipgloss.NewStyle().
Foreground(lipgloss.Color("#BD93F9")). // Purple
Background(lipgloss.Color("#282A36")). // Dark background
Bold(true).
Padding(0, 1)
t.DetailPanel = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F8F8F2")). // Light text
Padding(0, 1)
// Bar styles
t.HeaderBar = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F8F8F2")). // Light
Background(lipgloss.Color("#282A36")). // Dark background
Bold(true).
Padding(0, 1)
t.StatusBar = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F8F8F2")). // Light
Background(lipgloss.Color("#44475A")). // Gray background
Padding(0, 1)
// Text styles
t.MetaText = lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")) // Gray
t.AccentCyan = lipgloss.NewStyle().
Foreground(lipgloss.Color("#8BE9FD")) // Cyan
t.AccentGreen = lipgloss.NewStyle().
Foreground(lipgloss.Color("#50FA7B")) // Green
}
// apply256Color applies 256-color palette approximations.
func (t *Theme) apply256Color() {
// Badge styles
t.StoppedBadge = lipgloss.NewStyle().
Foreground(lipgloss.Color("229")). // Closest to #F1FA8C
Background(lipgloss.Color("235")). // Closest to #282A36
Bold(true).
Padding(0, 1).
MarginRight(1)
t.PermissionBadge = lipgloss.NewStyle().
Foreground(lipgloss.Color("203")). // Closest to #FF5555
Background(lipgloss.Color("235")). // Closest to #282A36
Bold(true).
Padding(0, 1).
MarginRight(1)
// Row styles
t.SelectedRow = lipgloss.NewStyle().
Background(lipgloss.Color("141")). // Closest to #BD93F9
Foreground(lipgloss.Color("235")). // Dark text for contrast
Bold(true)
t.NormalRow = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")) // White
// Panel styles
t.PanelBorder = lipgloss.NewStyle().
Foreground(lipgloss.Color("61")). // Closest to #6272A4
Border(lipgloss.RoundedBorder())
t.PanelHeader = lipgloss.NewStyle().
Foreground(lipgloss.Color("141")). // Closest to #BD93F9
Background(lipgloss.Color("235")). // Closest to #282A36
Bold(true).
Padding(0, 1)
t.DetailPanel = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")). // White
Padding(0, 1)
// Bar styles
t.HeaderBar = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")). // White
Background(lipgloss.Color("235")). // Closest to #282A36
Bold(true).
Padding(0, 1)
t.StatusBar = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")). // White
Background(lipgloss.Color("240")). // Closest to #44475A
Padding(0, 1)
// Text styles
t.MetaText = lipgloss.NewStyle().
Foreground(lipgloss.Color("244")) // Gray
t.AccentCyan = lipgloss.NewStyle().
Foreground(lipgloss.Color("117")) // Cyan
t.AccentGreen = lipgloss.NewStyle().
Foreground(lipgloss.Color("84")) // Green
}
// applyBasicColor applies basic 8-color fallback.
func (t *Theme) applyBasicColor() {
// Badge styles
t.StoppedBadge = lipgloss.NewStyle().
Foreground(lipgloss.Color("yellow")).
Bold(true).
Padding(0, 1).
MarginRight(1)
t.PermissionBadge = lipgloss.NewStyle().
Foreground(lipgloss.Color("red")).
Bold(true).
Padding(0, 1).
MarginRight(1)
// Row styles
t.SelectedRow = lipgloss.NewStyle().
Background(lipgloss.Color("magenta")).
Foreground(lipgloss.Color("black")).
Bold(true)
t.NormalRow = lipgloss.NewStyle().
Foreground(lipgloss.Color("white"))
// Panel styles
t.PanelBorder = lipgloss.NewStyle().
Foreground(lipgloss.Color("blue")).
Border(lipgloss.RoundedBorder())
t.PanelHeader = lipgloss.NewStyle().
Foreground(lipgloss.Color("magenta")).
Bold(true).
Padding(0, 1)
t.DetailPanel = lipgloss.NewStyle().
Foreground(lipgloss.Color("white")).
Padding(0, 1)
// Bar styles
t.HeaderBar = lipgloss.NewStyle().
Foreground(lipgloss.Color("white")).
Bold(true).
Padding(0, 1)
t.StatusBar = lipgloss.NewStyle().
Foreground(lipgloss.Color("white")).
Padding(0, 1)
// Text styles
t.MetaText = lipgloss.NewStyle().
Foreground(lipgloss.Color("black"))
t.AccentCyan = lipgloss.NewStyle().
Foreground(lipgloss.Color("cyan"))
t.AccentGreen = lipgloss.NewStyle().
Foreground(lipgloss.Color("green"))
}
// applyNoColor applies no-color fallback for dumb terminals.
func (t *Theme) applyNoColor() {
// All styles use basic styling without color
t.StoppedBadge = lipgloss.NewStyle().Bold(true).Padding(0, 1).MarginRight(1)
t.PermissionBadge = lipgloss.NewStyle().Bold(true).Padding(0, 1).MarginRight(1)
t.SelectedRow = lipgloss.NewStyle().Bold(true).Reverse(true)
t.NormalRow = lipgloss.NewStyle()
t.PanelBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder())
t.PanelHeader = lipgloss.NewStyle().Bold(true).Padding(0, 1)
t.DetailPanel = lipgloss.NewStyle().Padding(0, 1)
t.HeaderBar = lipgloss.NewStyle().Bold(true).Padding(0, 1)
t.StatusBar = lipgloss.NewStyle().Padding(0, 1)
t.MetaText = lipgloss.NewStyle()
t.AccentCyan = lipgloss.NewStyle()
t.AccentGreen = lipgloss.NewStyle()
}
// HasTrueColor returns true if terminal supports truecolor.
func (t *Theme) HasTrueColor() bool {
return t.hasTrueColor
}
// Has256Color returns true if terminal supports 256 colors.
func (t *Theme) Has256Color() bool {
return t.has256Color
}
// NoColor returns true if terminal has no color support.
func (t *Theme) NoColor() bool {
return t.noColor
}