From dd0ebbd6ce287934aa825ba7a59fb201e33ade6f Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 25 Jun 2026 00:50:59 -0400 Subject: [PATCH] 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 --- tui/model.go | 732 +++++++++++++++++++++++++++++++++++++++++++++++++++ tui/theme.go | 315 ++++++++++++++++++++++ 2 files changed, 1047 insertions(+) create mode 100644 tui/model.go create mode 100644 tui/theme.go diff --git a/tui/model.go b/tui/model.go new file mode 100644 index 0000000..5778a5c --- /dev/null +++ b/tui/model.go @@ -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) + 1–9 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 +} diff --git a/tui/theme.go b/tui/theme.go new file mode 100644 index 0000000..88c04df --- /dev/null +++ b/tui/theme.go @@ -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 +}