feat: introduce theme management and performance improvements (#7)

This commit is contained in:
Karol Broda
2025-12-24 10:49:03 +01:00
committed by GitHub
parent ec5a4ee046
commit 1021ba13aa
20 changed files with 877 additions and 172 deletions

View File

@@ -3,11 +3,12 @@ package cmd
import (
"fmt"
"os"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/config"
"github.com/spf13/cobra"
)
var (
cfgFile string
)
@@ -42,7 +43,7 @@ func init() {
// add top's flags to root so `snitch -l` works (defaults to top command)
cfg := config.Get()
rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)")
rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (see 'snitch themes')")
rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)")
rootCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
rootCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")

24
cmd/themes.go Normal file
View File

@@ -0,0 +1,24 @@
package cmd
import (
"fmt"
"github.com/karol-broda/snitch/internal/theme"
"github.com/spf13/cobra"
)
var themesCmd = &cobra.Command{
Use: "themes",
Short: "List available themes",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Available themes (default: %s):\n\n", theme.DefaultTheme)
for _, name := range theme.ListThemes() {
fmt.Printf(" %s\n", name)
}
},
}
func init() {
rootCmd.AddCommand(themesCmd)
}

View File

@@ -2,11 +2,11 @@ package cmd
import (
"log"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/tui"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/karol-broda/snitch/internal/config"
"github.com/karol-broda/snitch/internal/tui"
"github.com/spf13/cobra"
)
@@ -60,7 +60,7 @@ func init() {
cfg := config.Get()
// top-specific flags
topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)")
topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (see 'snitch themes')")
topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval")
topCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames")
topCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names")

View File

@@ -11,21 +11,76 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// set SNITCH_DEBUG_TIMING=1 to enable timing diagnostics
var debugTiming = os.Getenv("SNITCH_DEBUG_TIMING") != ""
func logTiming(label string, start time.Time, extra ...string) {
if !debugTiming {
return
}
elapsed := time.Since(start)
if len(extra) > 0 {
fmt.Fprintf(os.Stderr, "[timing] %s: %v (%s)\n", label, elapsed, extra[0])
} else {
fmt.Fprintf(os.Stderr, "[timing] %s: %v\n", label, elapsed)
}
}
// userCache caches uid to username mappings to avoid repeated lookups
var userCache = struct {
sync.RWMutex
m map[int]string
}{m: make(map[int]string)}
func lookupUsername(uid int) string {
userCache.RLock()
if username, exists := userCache.m[uid]; exists {
userCache.RUnlock()
return username
}
userCache.RUnlock()
start := time.Now()
username := strconv.Itoa(uid)
u, err := user.LookupId(strconv.Itoa(uid))
if err == nil && u != nil {
username = u.Username
}
elapsed := time.Since(start)
if debugTiming && elapsed > 10*time.Millisecond {
fmt.Fprintf(os.Stderr, "[timing] user.LookupId(%d) slow: %v\n", uid, elapsed)
}
userCache.Lock()
userCache.m[uid] = username
userCache.Unlock()
return username
}
// DefaultCollector implements the Collector interface using /proc filesystem
type DefaultCollector struct{}
// GetConnections fetches all network connections by parsing /proc files
func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
totalStart := time.Now()
defer func() { logTiming("GetConnections total", totalStart) }()
inodeStart := time.Now()
inodeMap, err := buildInodeToProcessMap()
logTiming("buildInodeToProcessMap", inodeStart, fmt.Sprintf("%d inodes", len(inodeMap)))
if err != nil {
return nil, fmt.Errorf("failed to build inode map: %w", err)
}
var connections []Connection
parseStart := time.Now()
tcpConns, err := parseProcNet("/proc/net/tcp", "tcp", 4, inodeMap)
if err == nil {
connections = append(connections, tcpConns...)
@@ -45,6 +100,7 @@ func (dc *DefaultCollector) GetConnections() ([]Connection, error) {
if err == nil {
connections = append(connections, udpConns6...)
}
logTiming("parseProcNet (all)", parseStart, fmt.Sprintf("%d connections", len(connections)))
return connections, nil
}
@@ -71,9 +127,13 @@ type processInfo struct {
user string
}
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
inodeMap := make(map[int64]*processInfo)
type inodeEntry struct {
inode int64
info *processInfo
}
func buildInodeToProcessMap() (map[int64]*processInfo, error) {
readDirStart := time.Now()
procDir, err := os.Open("/proc")
if err != nil {
return nil, err
@@ -85,47 +145,103 @@ func buildInodeToProcessMap() (map[int64]*processInfo, error) {
return nil, err
}
// collect pids first
pids := make([]int, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
pids = append(pids, pid)
}
logTiming(" readdir /proc", readDirStart, fmt.Sprintf("%d pids", len(pids)))
pidStr := entry.Name()
pid, err := strconv.Atoi(pidStr)
// process pids in parallel with limited concurrency
scanStart := time.Now()
const numWorkers = 8
pidChan := make(chan int, len(pids))
resultChan := make(chan []inodeEntry, len(pids))
var totalFDs atomic.Int64
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for pid := range pidChan {
entries := scanProcessSockets(pid)
if len(entries) > 0 {
totalFDs.Add(int64(len(entries)))
resultChan <- entries
}
}
}()
}
for _, pid := range pids {
pidChan <- pid
}
close(pidChan)
go func() {
wg.Wait()
close(resultChan)
}()
inodeMap := make(map[int64]*processInfo)
for entries := range resultChan {
for _, e := range entries {
inodeMap[e.inode] = e.info
}
}
logTiming(" scan all processes", scanStart, fmt.Sprintf("%d socket fds scanned", totalFDs.Load()))
return inodeMap, nil
}
func scanProcessSockets(pid int) []inodeEntry {
start := time.Now()
procInfo, err := getProcessInfo(pid)
if err != nil {
return nil
}
pidStr := strconv.Itoa(pid)
fdDir := filepath.Join("/proc", pidStr, "fd")
fdEntries, err := os.ReadDir(fdDir)
if err != nil {
return nil
}
var results []inodeEntry
for _, fdEntry := range fdEntries {
fdPath := filepath.Join(fdDir, fdEntry.Name())
link, err := os.Readlink(fdPath)
if err != nil {
continue
}
procInfo, err := getProcessInfo(pid)
if err != nil {
continue
}
fdDir := filepath.Join("/proc", pidStr, "fd")
fdEntries, err := os.ReadDir(fdDir)
if err != nil {
continue
}
for _, fdEntry := range fdEntries {
fdPath := filepath.Join(fdDir, fdEntry.Name())
link, err := os.Readlink(fdPath)
if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") {
inodeStr := link[8 : len(link)-1]
inode, err := strconv.ParseInt(inodeStr, 10, 64)
if err != nil {
continue
}
if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") {
inodeStr := link[8 : len(link)-1]
inode, err := strconv.ParseInt(inodeStr, 10, 64)
if err != nil {
continue
}
inodeMap[inode] = procInfo
}
results = append(results, inodeEntry{inode: inode, info: procInfo})
}
}
return inodeMap, nil
elapsed := time.Since(start)
if debugTiming && elapsed > 20*time.Millisecond {
fmt.Fprintf(os.Stderr, "[timing] slow process scan: pid=%d (%s) fds=%d time=%v\n",
pid, procInfo.command, len(fdEntries), elapsed)
}
return results
}
func getProcessInfo(pid int) (*processInfo, error) {
@@ -173,12 +289,7 @@ func getProcessInfo(pid int) (*processInfo, error) {
uid, err := strconv.Atoi(fields[1])
if err == nil {
info.uid = uid
u, err := user.LookupId(strconv.Itoa(uid))
if err == nil {
info.user = u.Username
} else {
info.user = strconv.Itoa(uid)
}
info.user = lookupUsername(uid)
}
}
break

View File

@@ -1,7 +1,10 @@
//go:build linux
package collector
import (
"testing"
"time"
)
func TestGetConnections(t *testing.T) {
@@ -13,4 +16,102 @@ func TestGetConnections(t *testing.T) {
// connections are dynamic, so just verify function succeeded
t.Logf("Successfully got %d connections", len(conns))
}
func TestGetConnectionsPerformance(t *testing.T) {
// measures performance to catch regressions
// run with: go test -v -run TestGetConnectionsPerformance
const maxDuration = 500 * time.Millisecond
const iterations = 5
// warm up caches first
_, err := GetConnections()
if err != nil {
t.Fatalf("warmup failed: %v", err)
}
var total time.Duration
var maxSeen time.Duration
for i := 0; i < iterations; i++ {
start := time.Now()
conns, err := GetConnections()
elapsed := time.Since(start)
if err != nil {
t.Fatalf("iteration %d failed: %v", i, err)
}
total += elapsed
if elapsed > maxSeen {
maxSeen = elapsed
}
t.Logf("iteration %d: %v (%d connections)", i+1, elapsed, len(conns))
}
avg := total / time.Duration(iterations)
t.Logf("average: %v, max: %v", avg, maxSeen)
if maxSeen > maxDuration {
t.Errorf("slowest iteration took %v, expected < %v", maxSeen, maxDuration)
}
}
func TestGetConnectionsColdCache(t *testing.T) {
// tests performance with cold user cache
// this simulates first run or after cache invalidation
const maxDuration = 2 * time.Second
clearUserCache()
start := time.Now()
conns, err := GetConnections()
elapsed := time.Since(start)
if err != nil {
t.Fatalf("GetConnections() failed: %v", err)
}
t.Logf("cold cache: %v (%d connections, %d cached users after)",
elapsed, len(conns), userCacheSize())
if elapsed > maxDuration {
t.Errorf("cold cache took %v, expected < %v", elapsed, maxDuration)
}
}
func BenchmarkGetConnections(b *testing.B) {
// warm cache benchmark - measures typical runtime
// run with: go test -bench=BenchmarkGetConnections -benchtime=5s
// warm up
_, _ = GetConnections()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = GetConnections()
}
}
func BenchmarkGetConnectionsColdCache(b *testing.B) {
// cold cache benchmark - measures worst-case with cache cleared each iteration
// run with: go test -bench=BenchmarkGetConnectionsColdCache -benchtime=10s
b.ResetTimer()
for i := 0; i < b.N; i++ {
clearUserCache()
_, _ = GetConnections()
}
}
func BenchmarkBuildInodeMap(b *testing.B) {
// benchmarks just the inode map building (most expensive part)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = buildInodeToProcessMap()
}
}

View File

@@ -0,0 +1,18 @@
//go:build linux
package collector
// clearUserCache clears the user lookup cache for testing
func clearUserCache() {
userCache.Lock()
userCache.m = make(map[int]string)
userCache.Unlock()
}
// userCacheSize returns the number of cached user entries
func userCacheSize() int {
userCache.RLock()
defer userCache.RUnlock()
return len(userCache.m)
}

View File

@@ -4,8 +4,10 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/karol-broda/snitch/internal/theme"
"github.com/spf13/viper"
)
@@ -92,7 +94,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("defaults.interval", "1s")
v.SetDefault("defaults.numeric", false)
v.SetDefault("defaults.fields", []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"})
v.SetDefault("defaults.theme", "auto")
v.SetDefault("defaults.theme", "ansi")
v.SetDefault("defaults.units", "auto")
v.SetDefault("defaults.color", "auto")
v.SetDefault("defaults.resolve", true)
@@ -127,7 +129,7 @@ func Get() *Config {
Interval: "1s",
Numeric: false,
Fields: []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"},
Theme: "auto",
Theme: "ansi",
Units: "auto",
Color: "auto",
Resolve: true,
@@ -154,7 +156,9 @@ func (c *Config) GetInterval() time.Duration {
// CreateExampleConfig creates an example configuration file
func CreateExampleConfig(path string) error {
exampleConfig := `# snitch configuration file
themeList := strings.Join(theme.ListThemes(), ", ")
exampleConfig := fmt.Sprintf(`# snitch configuration file
# See https://github.com/you/snitch for full documentation
[defaults]
@@ -167,8 +171,9 @@ numeric = false
# Default fields to display (comma-separated list)
fields = ["pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"]
# Default theme for TUI (dark, light, mono, auto)
theme = "auto"
# Default theme for TUI (ansi inherits terminal colors)
# Available: %s
theme = "%s"
# Default units for byte display (auto, si, iec)
units = "auto"
@@ -187,17 +192,17 @@ ipv6 = false
no_headers = false
output_format = "table"
sort_by = ""
`
`, themeList, theme.DefaultTheme)
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// Write config file
if err := os.WriteFile(path, []byte(exampleConfig), 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}

View File

@@ -2,12 +2,16 @@ package resolver
import (
"context"
"fmt"
"net"
"os"
"strconv"
"sync"
"time"
)
var debugTiming = os.Getenv("SNITCH_DEBUG_TIMING") != ""
// Resolver handles DNS and service name resolution with caching and timeouts
type Resolver struct {
timeout time.Duration
@@ -41,6 +45,7 @@ func (r *Resolver) ResolveAddr(addr string) string {
}
// Perform resolution with timeout
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
defer cancel()
@@ -55,6 +60,11 @@ func (r *Resolver) ResolveAddr(addr string) string {
}
}
elapsed := time.Since(start)
if debugTiming && elapsed > 50*time.Millisecond {
fmt.Fprintf(os.Stderr, "[timing] slow DNS lookup: %s -> %s (%v)\n", addr, resolved, elapsed)
}
// Cache the result
r.mutex.Lock()
r.cache[addr] = resolved

24
internal/theme/ansi.go Normal file
View File

@@ -0,0 +1,24 @@
package theme
// ANSI palette uses standard terminal colors (0-15)
// this allows the theme to inherit from the user's terminal color scheme
var paletteANSI = Palette{
Name: "ansi",
Fg: "15", // bright white
FgMuted: "7", // white
FgSubtle: "8", // bright black (gray)
Bg: "0", // black
BgMuted: "0", // black
Border: "8", // bright black (gray)
Red: "1", // red
Green: "2", // green
Yellow: "3", // yellow
Blue: "4", // blue
Magenta: "5", // magenta
Cyan: "6", // cyan
Orange: "3", // yellow (ansi has no orange, fallback to yellow)
Gray: "8", // bright black
}

View File

@@ -0,0 +1,87 @@
package theme
// catppuccin mocha (dark)
// https://github.com/catppuccin/catppuccin
var paletteCatppuccinMocha = Palette{
Name: "catppuccin-mocha",
Fg: "#cdd6f4", // text
FgMuted: "#a6adc8", // subtext0
FgSubtle: "#6c7086", // overlay0
Bg: "#1e1e2e", // base
BgMuted: "#313244", // surface0
Border: "#45475a", // surface1
Red: "#f38ba8",
Green: "#a6e3a1",
Yellow: "#f9e2af",
Blue: "#89b4fa",
Magenta: "#cba6f7", // mauve
Cyan: "#94e2d5", // teal
Orange: "#fab387", // peach
Gray: "#585b70", // surface2
}
// catppuccin macchiato (medium-dark)
var paletteCatppuccinMacchiato = Palette{
Name: "catppuccin-macchiato",
Fg: "#cad3f5", // text
FgMuted: "#a5adcb", // subtext0
FgSubtle: "#6e738d", // overlay0
Bg: "#24273a", // base
BgMuted: "#363a4f", // surface0
Border: "#494d64", // surface1
Red: "#ed8796",
Green: "#a6da95",
Yellow: "#eed49f",
Blue: "#8aadf4",
Magenta: "#c6a0f6", // mauve
Cyan: "#8bd5ca", // teal
Orange: "#f5a97f", // peach
Gray: "#5b6078", // surface2
}
// catppuccin frappe (medium)
var paletteCatppuccinFrappe = Palette{
Name: "catppuccin-frappe",
Fg: "#c6d0f5", // text
FgMuted: "#a5adce", // subtext0
FgSubtle: "#737994", // overlay0
Bg: "#303446", // base
BgMuted: "#414559", // surface0
Border: "#51576d", // surface1
Red: "#e78284",
Green: "#a6d189",
Yellow: "#e5c890",
Blue: "#8caaee",
Magenta: "#ca9ee6", // mauve
Cyan: "#81c8be", // teal
Orange: "#ef9f76", // peach
Gray: "#626880", // surface2
}
// catppuccin latte (light)
var paletteCatppuccinLatte = Palette{
Name: "catppuccin-latte",
Fg: "#4c4f69", // text
FgMuted: "#6c6f85", // subtext0
FgSubtle: "#9ca0b0", // overlay0
Bg: "#eff1f5", // base
BgMuted: "#ccd0da", // surface0
Border: "#bcc0cc", // surface1
Red: "#d20f39",
Green: "#40a02b",
Yellow: "#df8e1d",
Blue: "#1e66f5",
Magenta: "#8839ef", // mauve
Cyan: "#179299", // teal
Orange: "#fe640b", // peach
Gray: "#acb0be", // surface2
}

24
internal/theme/dracula.go Normal file
View File

@@ -0,0 +1,24 @@
package theme
// dracula theme
// https://draculatheme.com/
var paletteDracula = Palette{
Name: "dracula",
Fg: "#f8f8f2", // foreground
FgMuted: "#f8f8f2", // foreground
FgSubtle: "#6272a4", // comment
Bg: "#282a36", // background
BgMuted: "#44475a", // selection
Border: "#44475a", // selection
Red: "#ff5555",
Green: "#50fa7b",
Yellow: "#f1fa8c",
Blue: "#6272a4", // dracula uses comment color for blue tones
Magenta: "#bd93f9", // purple
Cyan: "#8be9fd",
Orange: "#ffb86c",
Gray: "#6272a4", // comment
}

45
internal/theme/gruvbox.go Normal file
View File

@@ -0,0 +1,45 @@
package theme
// gruvbox dark
// https://github.com/morhetz/gruvbox
var paletteGruvboxDark = Palette{
Name: "gruvbox-dark",
Fg: "#ebdbb2", // fg
FgMuted: "#d5c4a1", // fg2
FgSubtle: "#a89984", // fg4
Bg: "#282828", // bg
BgMuted: "#3c3836", // bg1
Border: "#504945", // bg2
Red: "#fb4934",
Green: "#b8bb26",
Yellow: "#fabd2f",
Blue: "#83a598",
Magenta: "#d3869b", // purple
Cyan: "#8ec07c", // aqua
Orange: "#fe8019",
Gray: "#928374",
}
// gruvbox light
var paletteGruvboxLight = Palette{
Name: "gruvbox-light",
Fg: "#3c3836", // fg
FgMuted: "#504945", // fg2
FgSubtle: "#7c6f64", // fg4
Bg: "#fbf1c7", // bg
BgMuted: "#ebdbb2", // bg1
Border: "#d5c4a1", // bg2
Red: "#cc241d",
Green: "#98971a",
Yellow: "#d79921",
Blue: "#458588",
Magenta: "#b16286", // purple
Cyan: "#689d6a", // aqua
Orange: "#d65d0e",
Gray: "#928374",
}

49
internal/theme/mono.go Normal file
View File

@@ -0,0 +1,49 @@
package theme
import "github.com/charmbracelet/lipgloss"
// createMonoTheme creates a monochrome theme (no colors)
// useful for accessibility, piping output, or minimal terminals
func createMonoTheme() *Theme {
baseStyle := lipgloss.NewStyle()
boldStyle := lipgloss.NewStyle().Bold(true)
return &Theme{
Name: "mono",
Styles: Styles{
Header: boldStyle,
Border: baseStyle,
Selected: boldStyle,
Watched: boldStyle,
Normal: baseStyle,
Error: boldStyle,
Success: boldStyle,
Warning: boldStyle,
Footer: baseStyle,
Background: baseStyle,
Proto: ProtoStyles{
TCP: baseStyle,
UDP: baseStyle,
Unix: baseStyle,
TCP6: baseStyle,
UDP6: baseStyle,
},
State: StateStyles{
Listen: baseStyle,
Established: baseStyle,
TimeWait: baseStyle,
CloseWait: baseStyle,
SynSent: baseStyle,
SynRecv: baseStyle,
FinWait1: baseStyle,
FinWait2: baseStyle,
Closing: baseStyle,
LastAck: baseStyle,
Closed: baseStyle,
},
},
}
}

24
internal/theme/nord.go Normal file
View File

@@ -0,0 +1,24 @@
package theme
// nord theme
// https://www.nordtheme.com/
var paletteNord = Palette{
Name: "nord",
Fg: "#eceff4", // snow storm - nord6
FgMuted: "#d8dee9", // snow storm - nord4
FgSubtle: "#4c566a", // polar night - nord3
Bg: "#2e3440", // polar night - nord0
BgMuted: "#3b4252", // polar night - nord1
Border: "#434c5e", // polar night - nord2
Red: "#bf616a", // aurora - nord11
Green: "#a3be8c", // aurora - nord14
Yellow: "#ebcb8b", // aurora - nord13
Blue: "#81a1c1", // frost - nord9
Magenta: "#b48ead", // aurora - nord15
Cyan: "#88c0d0", // frost - nord8
Orange: "#d08770", // aurora - nord12
Gray: "#4c566a", // polar night - nord3
}

View File

@@ -0,0 +1,24 @@
package theme
// one dark theme (atom editor)
// https://github.com/atom/atom/tree/master/packages/one-dark-syntax
var paletteOneDark = Palette{
Name: "one-dark",
Fg: "#abb2bf", // foreground
FgMuted: "#9da5b4", // foreground muted
FgSubtle: "#5c6370", // comment
Bg: "#282c34", // background
BgMuted: "#21252b", // gutter background
Border: "#3e4451", // selection
Red: "#e06c75",
Green: "#98c379",
Yellow: "#e5c07b",
Blue: "#61afef",
Magenta: "#c678dd", // purple
Cyan: "#56b6c2",
Orange: "#d19a66",
Gray: "#5c6370", // comment
}

111
internal/theme/palette.go Normal file
View File

@@ -0,0 +1,111 @@
package theme
import (
"strconv"
"github.com/charmbracelet/lipgloss"
)
// Palette defines the semantic colors for a theme
type Palette struct {
Name string
// base colors
Fg string // primary foreground
FgMuted string // secondary/muted foreground
FgSubtle string // subtle/disabled foreground
Bg string // primary background
BgMuted string // secondary background (selections, highlights)
Border string // border color
// semantic colors
Red string
Green string
Yellow string
Blue string
Magenta string
Cyan string
Orange string
Gray string
}
// Color converts a palette color string to a lipgloss.TerminalColor.
// If the string is 1-2 characters, it's treated as an ANSI color code.
// Otherwise, it's treated as a hex color.
func (p *Palette) Color(c string) lipgloss.TerminalColor {
if c == "" {
return lipgloss.NoColor{}
}
if len(c) <= 2 {
n, err := strconv.Atoi(c)
if err == nil {
return lipgloss.ANSIColor(n)
}
}
return lipgloss.Color(c)
}
// ToTheme converts a Palette to a Theme with lipgloss styles
func (p *Palette) ToTheme() *Theme {
return &Theme{
Name: p.Name,
Styles: Styles{
Header: lipgloss.NewStyle().
Bold(true).
Foreground(p.Color(p.Fg)),
Border: lipgloss.NewStyle().
Foreground(p.Color(p.Border)),
Selected: lipgloss.NewStyle().
Bold(true).
Foreground(p.Color(p.Fg)),
Watched: lipgloss.NewStyle().
Bold(true).
Foreground(p.Color(p.Orange)),
Normal: lipgloss.NewStyle().
Foreground(p.Color(p.FgMuted)),
Error: lipgloss.NewStyle().
Foreground(p.Color(p.Red)),
Success: lipgloss.NewStyle().
Foreground(p.Color(p.Green)),
Warning: lipgloss.NewStyle().
Foreground(p.Color(p.Yellow)),
Footer: lipgloss.NewStyle().
Foreground(p.Color(p.FgSubtle)),
Background: lipgloss.NewStyle(),
Proto: ProtoStyles{
TCP: lipgloss.NewStyle().Foreground(p.Color(p.Green)),
UDP: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)),
Unix: lipgloss.NewStyle().Foreground(p.Color(p.Gray)),
TCP6: lipgloss.NewStyle().Foreground(p.Color(p.Cyan)),
UDP6: lipgloss.NewStyle().Foreground(p.Color(p.Blue)),
},
State: StateStyles{
Listen: lipgloss.NewStyle().Foreground(p.Color(p.Green)),
Established: lipgloss.NewStyle().Foreground(p.Color(p.Blue)),
TimeWait: lipgloss.NewStyle().Foreground(p.Color(p.Yellow)),
CloseWait: lipgloss.NewStyle().Foreground(p.Color(p.Orange)),
SynSent: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)),
SynRecv: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)),
FinWait1: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
FinWait2: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
Closing: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
LastAck: lipgloss.NewStyle().Foreground(p.Color(p.Red)),
Closed: lipgloss.NewStyle().Foreground(p.Color(p.Gray)),
},
},
}
}

14
internal/theme/readme.md Normal file
View File

@@ -0,0 +1,14 @@
# theme Palettes
the color palettes in this directory were generated by an LLM agent (Claude Opus 4.5) using web search to fetch the official color specifications from each themes documentation
as it is with llm agents its possible the colors may be wrong
Sources:
- [Catppuccin](https://github.com/catppuccin/catppuccin)
- [Dracula](https://draculatheme.com/)
- [Gruvbox](https://github.com/morhetz/gruvbox)
- [Nord](https://www.nordtheme.com/)
- [One Dark](https://github.com/atom/one-dark-syntax)
- [Solarized](https://ethanschoonover.com/solarized/)
- [Tokyo Night](https://github.com/enkia/tokyo-night-vscode-theme)

View File

@@ -0,0 +1,45 @@
package theme
// solarized dark theme
// https://ethanschoonover.com/solarized/
var paletteSolarizedDark = Palette{
Name: "solarized-dark",
Fg: "#839496", // base0
FgMuted: "#93a1a1", // base1
FgSubtle: "#586e75", // base01
Bg: "#002b36", // base03
BgMuted: "#073642", // base02
Border: "#073642", // base02
Red: "#dc322f",
Green: "#859900",
Yellow: "#b58900",
Blue: "#268bd2",
Magenta: "#d33682",
Cyan: "#2aa198",
Orange: "#cb4b16",
Gray: "#657b83", // base00
}
// solarized light theme
var paletteSolarizedLight = Palette{
Name: "solarized-light",
Fg: "#657b83", // base00
FgMuted: "#586e75", // base01
FgSubtle: "#93a1a1", // base1
Bg: "#fdf6e3", // base3
BgMuted: "#eee8d5", // base2
Border: "#eee8d5", // base2
Red: "#dc322f",
Green: "#859900",
Yellow: "#b58900",
Blue: "#268bd2",
Magenta: "#d33682",
Cyan: "#2aa198",
Orange: "#cb4b16",
Gray: "#839496", // base0
}

View File

@@ -1,6 +1,7 @@
package theme
import (
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
@@ -52,152 +53,73 @@ type StateStyles struct {
Closed lipgloss.Style
}
var (
themes map[string]*Theme
)
var themes map[string]*Theme
func init() {
themes = map[string]*Theme{
"default": createAdaptiveTheme(),
"mono": createMonoTheme(),
}
themes = make(map[string]*Theme)
// ansi theme (default) - inherits from terminal colors
themes["ansi"] = paletteANSI.ToTheme()
// catppuccin variants
themes["catppuccin-mocha"] = paletteCatppuccinMocha.ToTheme()
themes["catppuccin-macchiato"] = paletteCatppuccinMacchiato.ToTheme()
themes["catppuccin-frappe"] = paletteCatppuccinFrappe.ToTheme()
themes["catppuccin-latte"] = paletteCatppuccinLatte.ToTheme()
// gruvbox variants
themes["gruvbox-dark"] = paletteGruvboxDark.ToTheme()
themes["gruvbox-light"] = paletteGruvboxLight.ToTheme()
// dracula
themes["dracula"] = paletteDracula.ToTheme()
// nord
themes["nord"] = paletteNord.ToTheme()
// tokyo night variants
themes["tokyo-night"] = paletteTokyoNight.ToTheme()
themes["tokyo-night-storm"] = paletteTokyoNightStorm.ToTheme()
themes["tokyo-night-light"] = paletteTokyoNightLight.ToTheme()
// solarized variants
themes["solarized-dark"] = paletteSolarizedDark.ToTheme()
themes["solarized-light"] = paletteSolarizedLight.ToTheme()
// one dark
themes["one-dark"] = paletteOneDark.ToTheme()
// monochrome (no colors)
themes["mono"] = createMonoTheme()
}
// GetTheme returns a theme by name, with auto-detection support
// DefaultTheme is the theme used when none is specified
const DefaultTheme = "ansi"
// GetTheme returns a theme by name
func GetTheme(name string) *Theme {
if name == "auto" {
// lipgloss handles adaptive colors, so we just return the default
return themes["default"]
if name == "" || name == "auto" || name == "default" {
return themes[DefaultTheme]
}
if theme, exists := themes[name]; exists {
return theme
}
// a specific theme was requested (e.g. "dark", "light"), but we now use adaptive
// so we can just return the default theme and lipgloss will handle it
if name == "dark" || name == "light" {
return themes["default"]
}
// fallback to default
return themes["default"]
return themes[DefaultTheme]
}
// ListThemes returns available theme names
// ListThemes returns available theme names sorted alphabetically
func ListThemes() []string {
var names []string
names := make([]string, 0, len(themes))
for name := range themes {
names = append(names, name)
}
sort.Strings(names)
return names
}
// createAdaptiveTheme creates a clean, minimal theme
func createAdaptiveTheme() *Theme {
return &Theme{
Name: "default",
Styles: Styles{
Header: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#F9FAFB"}),
Watched: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"}),
Border: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#D1D5DB", Dark: "#374151"}),
Selected: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#F9FAFB"}),
Normal: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#9CA3AF"}),
Error: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
Success: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
Warning: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}),
Footer: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#9CA3AF", Dark: "#6B7280"}),
Background: lipgloss.NewStyle(),
Proto: ProtoStyles{
TCP: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
UDP: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
Unix: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#9CA3AF"}),
TCP6: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
UDP6: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
},
State: StateStyles{
Listen: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}),
Established: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#2563EB", Dark: "#60A5FA"}),
TimeWait: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}),
CloseWait: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}),
SynSent: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
SynRecv: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}),
FinWait1: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
FinWait2: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
Closing: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
LastAck: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}),
Closed: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9CA3AF", Dark: "#6B7280"}),
},
},
}
}
// createMonoTheme creates a monochrome theme (no colors)
func createMonoTheme() *Theme {
baseStyle := lipgloss.NewStyle()
boldStyle := lipgloss.NewStyle().Bold(true)
return &Theme{
Name: "mono",
Styles: Styles{
Header: boldStyle,
Border: baseStyle,
Selected: boldStyle,
Normal: baseStyle,
Error: boldStyle,
Success: boldStyle,
Warning: boldStyle,
Footer: baseStyle,
Background: baseStyle,
Proto: ProtoStyles{
TCP: baseStyle,
UDP: baseStyle,
Unix: baseStyle,
TCP6: baseStyle,
UDP6: baseStyle,
},
State: StateStyles{
Listen: baseStyle,
Established: baseStyle,
TimeWait: baseStyle,
CloseWait: baseStyle,
SynSent: baseStyle,
SynRecv: baseStyle,
FinWait1: baseStyle,
FinWait2: baseStyle,
Closing: baseStyle,
LastAck: baseStyle,
Closed: baseStyle,
},
},
}
}
// GetProtoStyle returns the appropriate style for a protocol
func (s *Styles) GetProtoStyle(proto string) lipgloss.Style {
switch strings.ToLower(proto) {

View File

@@ -0,0 +1,66 @@
package theme
// tokyo night theme
// https://github.com/enkia/tokyo-night-vscode-theme
var paletteTokyoNight = Palette{
Name: "tokyo-night",
Fg: "#c0caf5", // foreground
FgMuted: "#a9b1d6", // foreground dark
FgSubtle: "#565f89", // comment
Bg: "#1a1b26", // background
BgMuted: "#24283b", // background highlight
Border: "#414868", // border
Red: "#f7768e",
Green: "#9ece6a",
Yellow: "#e0af68",
Blue: "#7aa2f7",
Magenta: "#bb9af7", // purple
Cyan: "#7dcfff",
Orange: "#ff9e64",
Gray: "#565f89", // comment
}
// tokyo night storm variant
var paletteTokyoNightStorm = Palette{
Name: "tokyo-night-storm",
Fg: "#c0caf5", // foreground
FgMuted: "#a9b1d6", // foreground dark
FgSubtle: "#565f89", // comment
Bg: "#24283b", // background (storm is slightly lighter)
BgMuted: "#1f2335", // background dark
Border: "#414868", // border
Red: "#f7768e",
Green: "#9ece6a",
Yellow: "#e0af68",
Blue: "#7aa2f7",
Magenta: "#bb9af7", // purple
Cyan: "#7dcfff",
Orange: "#ff9e64",
Gray: "#565f89", // comment
}
// tokyo night light variant
var paletteTokyoNightLight = Palette{
Name: "tokyo-night-light",
Fg: "#343b58", // foreground
FgMuted: "#565a6e", // foreground dark
FgSubtle: "#9699a3", // comment
Bg: "#d5d6db", // background
BgMuted: "#cbccd1", // background highlight
Border: "#b4b5b9", // border
Red: "#8c4351",
Green: "#485e30",
Yellow: "#8f5e15",
Blue: "#34548a",
Magenta: "#5a4a78", // purple
Cyan: "#0f4b6e",
Orange: "#965027",
Gray: "#9699a3", // comment
}