e38cce5f1f
Bundles the pending TUI work into a coherent batch. Bug fixes from external review: * expandPlaceholders: single-pass alternation regex over the original input prevents `#p\d+` / `#img\d+` tokens inside pasted content from being re-expanded after the bracket form is inlined. * /incognito: gate savePromptHistory and the Ctrl+V image-write branch on `!m.incognito` so the no-persistence contract holds. * history.txt: write at mode 0600 (chmod existing 0644 files), create parent dir at 0700, truncate to 500 entries on every save, slog.Warn on errors instead of swallowing. * triggerPickerAction: guard m.config.Engine before SetModel, matching the /model handler. * Picker key handler: navigation/enter/q consume, escape/ctrl+c close the picker AND fall through to global handlers (so streaming cancel and double-tap quit work with an overlay open), default swallows stray input. * Paste line count: report total non-empty lines instead of newline count, ignoring trailing newlines (no more "+0 lines" for "abc"). * Ctrl+O restored to expand-output; Ctrl+Y is the new copy-response bind. /keys help text updated; picker help entries reordered. * Tighter perms on .gnoma/pasted_image_*.png (0600). Race-safety refactor: ApplyTheme used to mutate ~25 package-level lipgloss styles in place. Replaced with an immutable themeStyles snapshot and atomic.Pointer[themeStyles] swap. Readers go through a theme() helper (one atomic load) instead of touching package vars directly. No locks, no nested-RLock risk if rendering ever moves off-thread. Includes pre-existing in-flight work: TUISection in config with persistent theme/vim settings; /copy /theme /vim slash commands; provider-name completion; session.SetProvider for the provider picker. Tests: placeholder_test.go (6 regression + happy-path cases including the pasted-content collision), history_test.go (5 cases covering perms on new and existing files, on-disk truncation, blank-input, newline flattening), provider_test.go (provider switching + picker transitions + SLM gating).
247 lines
7.5 KiB
Go
247 lines
7.5 KiB
Go
package tui
|
|
|
|
import (
|
|
"image/color"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"charm.land/lipgloss/v2"
|
|
"somegit.dev/Owlibou/gnoma/internal/permission"
|
|
)
|
|
|
|
// Theme represents a custom color palette for the TUI.
|
|
type Theme struct {
|
|
Name string
|
|
Purple color.Color
|
|
Blue color.Color
|
|
Green color.Color
|
|
Red color.Color
|
|
Yellow color.Color
|
|
Peach color.Color
|
|
Teal color.Color
|
|
Text color.Color
|
|
Subtext color.Color
|
|
Overlay color.Color
|
|
Surface color.Color
|
|
Mantle color.Color
|
|
}
|
|
|
|
// Predefined themes
|
|
var Themes = []Theme{
|
|
{
|
|
Name: "catppuccin",
|
|
Purple: lipgloss.Color("#CBA6F7"),
|
|
Blue: lipgloss.Color("#89B4FA"),
|
|
Green: lipgloss.Color("#A6E3A1"),
|
|
Red: lipgloss.Color("#F38BA8"),
|
|
Yellow: lipgloss.Color("#F9E2AF"),
|
|
Peach: lipgloss.Color("#FAB387"),
|
|
Teal: lipgloss.Color("#94E2D5"),
|
|
Text: lipgloss.Color("#CDD6F4"),
|
|
Subtext: lipgloss.Color("#A6ADC8"),
|
|
Overlay: lipgloss.Color("#6C7086"),
|
|
Surface: lipgloss.Color("#313244"),
|
|
Mantle: lipgloss.Color("#181825"),
|
|
},
|
|
{
|
|
Name: "nord",
|
|
Purple: lipgloss.Color("#B48EAD"),
|
|
Blue: lipgloss.Color("#81A1C1"),
|
|
Green: lipgloss.Color("#A3BE8C"),
|
|
Red: lipgloss.Color("#BF616A"),
|
|
Yellow: lipgloss.Color("#EBCB8B"),
|
|
Peach: lipgloss.Color("#D08770"),
|
|
Teal: lipgloss.Color("#88C0D0"),
|
|
Text: lipgloss.Color("#D8DEE9"),
|
|
Subtext: lipgloss.Color("#E5E9F0"),
|
|
Overlay: lipgloss.Color("#4C566A"),
|
|
Surface: lipgloss.Color("#3B4252"),
|
|
Mantle: lipgloss.Color("#2E3440"),
|
|
},
|
|
{
|
|
Name: "gruvbox",
|
|
Purple: lipgloss.Color("#d3869b"),
|
|
Blue: lipgloss.Color("#83a598"),
|
|
Green: lipgloss.Color("#b8bb26"),
|
|
Red: lipgloss.Color("#fb4934"),
|
|
Yellow: lipgloss.Color("#fabd2f"),
|
|
Peach: lipgloss.Color("#fe8019"),
|
|
Teal: lipgloss.Color("#8ec07c"),
|
|
Text: lipgloss.Color("#ebdbb2"),
|
|
Subtext: lipgloss.Color("#a89984"),
|
|
Overlay: lipgloss.Color("#928374"),
|
|
Surface: lipgloss.Color("#3c3836"),
|
|
Mantle: lipgloss.Color("#282828"),
|
|
},
|
|
{
|
|
Name: "monokai",
|
|
Purple: lipgloss.Color("#ae81ff"),
|
|
Blue: lipgloss.Color("#66d9ef"),
|
|
Green: lipgloss.Color("#a6e22e"),
|
|
Red: lipgloss.Color("#f92672"),
|
|
Yellow: lipgloss.Color("#e6db74"),
|
|
Peach: lipgloss.Color("#fd971f"),
|
|
Teal: lipgloss.Color("#a1efe4"),
|
|
Text: lipgloss.Color("#f8f8f2"),
|
|
Subtext: lipgloss.Color("#cfcfc2"),
|
|
Overlay: lipgloss.Color("#75715e"),
|
|
Surface: lipgloss.Color("#272822"),
|
|
Mantle: lipgloss.Color("#1e1f1c"),
|
|
},
|
|
{
|
|
Name: "solarized_light",
|
|
Purple: lipgloss.Color("#6c71c4"),
|
|
Blue: lipgloss.Color("#268bd2"),
|
|
Green: lipgloss.Color("#859900"),
|
|
Red: lipgloss.Color("#dc322f"),
|
|
Yellow: lipgloss.Color("#b58900"),
|
|
Peach: lipgloss.Color("#cb4b16"),
|
|
Teal: lipgloss.Color("#2aa198"),
|
|
Text: lipgloss.Color("#586e75"),
|
|
Subtext: lipgloss.Color("#657b83"),
|
|
Overlay: lipgloss.Color("#93a1a1"),
|
|
Surface: lipgloss.Color("#eee8d5"),
|
|
Mantle: lipgloss.Color("#fdf6e3"),
|
|
},
|
|
}
|
|
|
|
// themeStyles is the immutable snapshot of the active palette and the
|
|
// pre-built lipgloss styles derived from it. ApplyTheme builds a fresh
|
|
// snapshot and stores it atomically; readers Load() the pointer once and
|
|
// see a coherent view, so no mutex is needed even if rendering ever moves
|
|
// off the bubbletea event-loop goroutine.
|
|
type themeStyles struct {
|
|
name string
|
|
|
|
cPurple color.Color
|
|
cBlue color.Color
|
|
cGreen color.Color
|
|
cRed color.Color
|
|
cYellow color.Color
|
|
cPeach color.Color
|
|
cTeal color.Color
|
|
cText color.Color
|
|
cSubtext color.Color
|
|
cOverlay color.Color
|
|
cSurface color.Color
|
|
cMantle color.Color
|
|
|
|
modeColors map[permission.Mode]color.Color
|
|
|
|
sHeaderBrand lipgloss.Style
|
|
sHeaderModel lipgloss.Style
|
|
sHeaderDim lipgloss.Style
|
|
sUserLabel lipgloss.Style
|
|
styleAssistantLabel lipgloss.Style
|
|
sToolOutput lipgloss.Style
|
|
sToolResult lipgloss.Style
|
|
sSystem lipgloss.Style
|
|
sError lipgloss.Style
|
|
sHint lipgloss.Style
|
|
sCursor lipgloss.Style
|
|
sDiffAdd lipgloss.Style
|
|
sDiffRemove lipgloss.Style
|
|
sText lipgloss.Style
|
|
sThinkingLabel lipgloss.Style
|
|
sThinkingBody lipgloss.Style
|
|
sStatusBar lipgloss.Style
|
|
sStatusHighlight lipgloss.Style
|
|
sStatusDim lipgloss.Style
|
|
sStatusStreaming lipgloss.Style
|
|
sStatusBranch lipgloss.Style
|
|
sStatusIncognito lipgloss.Style
|
|
}
|
|
|
|
var activeStyles atomic.Pointer[themeStyles]
|
|
|
|
// theme returns the currently-active style snapshot. The returned pointer
|
|
// must be treated as read-only; ApplyTheme never mutates an existing
|
|
// snapshot in place.
|
|
func theme() *themeStyles {
|
|
return activeStyles.Load()
|
|
}
|
|
|
|
// ModeColor returns the color for a permission mode under the active theme.
|
|
func ModeColor(mode permission.Mode) color.Color {
|
|
t := theme()
|
|
if c, ok := t.modeColors[mode]; ok {
|
|
return c
|
|
}
|
|
return t.cOverlay
|
|
}
|
|
|
|
// Initialize with catppuccin on package load.
|
|
func init() {
|
|
ApplyTheme("catppuccin")
|
|
}
|
|
|
|
// ApplyTheme builds a fresh themeStyles snapshot for the named theme and
|
|
// atomically swaps it in as the active one. Concurrent reads via theme()
|
|
// see either the previous snapshot or the new one — never a half-built
|
|
// state. Returns false if name does not match a known theme.
|
|
func ApplyTheme(name string) bool {
|
|
var src *Theme
|
|
for i := range Themes {
|
|
tName := strings.ReplaceAll(strings.ToLower(Themes[i].Name), "_", "-")
|
|
sName := strings.ReplaceAll(strings.ToLower(name), "_", "-")
|
|
if tName == sName {
|
|
src = &Themes[i]
|
|
break
|
|
}
|
|
}
|
|
if src == nil {
|
|
return false
|
|
}
|
|
|
|
t := &themeStyles{
|
|
name: src.Name,
|
|
cPurple: src.Purple,
|
|
cBlue: src.Blue,
|
|
cGreen: src.Green,
|
|
cRed: src.Red,
|
|
cYellow: src.Yellow,
|
|
cPeach: src.Peach,
|
|
cTeal: src.Teal,
|
|
cText: src.Text,
|
|
cSubtext: src.Subtext,
|
|
cOverlay: src.Overlay,
|
|
cSurface: src.Surface,
|
|
cMantle: src.Mantle,
|
|
}
|
|
|
|
t.modeColors = map[permission.Mode]color.Color{
|
|
permission.ModeBypass: t.cGreen,
|
|
permission.ModeDefault: t.cBlue,
|
|
permission.ModePlan: t.cTeal,
|
|
permission.ModeAcceptEdits: t.cPurple,
|
|
permission.ModeAuto: t.cPeach,
|
|
permission.ModeDeny: t.cRed,
|
|
}
|
|
|
|
t.sHeaderBrand = lipgloss.NewStyle().Background(t.cPurple).Foreground(t.cMantle).Bold(true).Padding(0, 1)
|
|
t.sHeaderModel = lipgloss.NewStyle().Foreground(t.cGreen).Bold(true)
|
|
t.sHeaderDim = lipgloss.NewStyle().Foreground(t.cOverlay)
|
|
t.sUserLabel = lipgloss.NewStyle().Foreground(t.cBlue).Bold(true)
|
|
t.styleAssistantLabel = lipgloss.NewStyle().Foreground(t.cPurple).Bold(true)
|
|
t.sToolOutput = lipgloss.NewStyle().Foreground(t.cGreen)
|
|
t.sToolResult = lipgloss.NewStyle().Foreground(t.cOverlay)
|
|
t.sSystem = lipgloss.NewStyle().Foreground(t.cYellow)
|
|
t.sError = lipgloss.NewStyle().Foreground(t.cRed)
|
|
t.sHint = lipgloss.NewStyle().Foreground(t.cOverlay)
|
|
t.sCursor = lipgloss.NewStyle().Foreground(t.cPurple)
|
|
t.sDiffAdd = lipgloss.NewStyle().Foreground(t.cGreen)
|
|
t.sDiffRemove = lipgloss.NewStyle().Foreground(t.cRed)
|
|
t.sText = lipgloss.NewStyle().Foreground(t.cText)
|
|
t.sThinkingLabel = lipgloss.NewStyle().Foreground(t.cOverlay).Italic(true)
|
|
t.sThinkingBody = lipgloss.NewStyle().Foreground(t.cOverlay).Italic(true)
|
|
t.sStatusBar = lipgloss.NewStyle().Foreground(t.cSubtext)
|
|
t.sStatusHighlight = lipgloss.NewStyle().Foreground(t.cPurple).Bold(true)
|
|
t.sStatusDim = lipgloss.NewStyle().Foreground(t.cOverlay)
|
|
t.sStatusStreaming = lipgloss.NewStyle().Foreground(t.cYellow).Bold(true)
|
|
t.sStatusBranch = lipgloss.NewStyle().Foreground(t.cGreen)
|
|
t.sStatusIncognito = lipgloss.NewStyle().Foreground(t.cYellow)
|
|
|
|
activeStyles.Store(t)
|
|
return true
|
|
}
|