feat(config): add theme customization (#81)

Added theme customization via config.toml
#17
```
[theme]
border = "#665C54"
accent = "#FABD2F"
muted = "#928374"
text = "#EBDBB2"
text_sel = "#FBF1C7"
match = "#FE8019"
desc = "#A89984"
desc_sel = "#EBDBB2"
sel_bg = "#504945"
scroll_info = "#83A598"
ghost_text = "#7C6F64"
hist= "#fe8019"
hist_sel = "#d65d0e"
sys = "#fb4934"
sys_sel = "#cc241d"
alias = "#b8bb26"
alias_sel = "#98971a"


```

---------

Co-authored-by: verse91 <versedev.store@proton.me>
Co-authored-by: VERSE <sunnyone0901@gmail.com>
This commit is contained in:
Bruce12
2026-08-04 13:49:10 +07:00
committed by GitHub
co-authored by verse91 VERSE
parent b79d25a082
commit b14b26e663
10 changed files with 494 additions and 179 deletions
+106
View File
@@ -0,0 +1,106 @@
package root
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/versenilvis/iris/internal/config"
)
var ThemeCmd = &cobra.Command{
Use: "theme",
Short: "manage iris theme",
}
var ThemeInitCmd = &cobra.Command{
Use: "init",
Short: "initialize default theme file with comments",
Run: func(cmd *cobra.Command, args []string) {
path, err := config.ThemePath()
if err != nil {
fmt.Printf("failed to get theme path: %v\n", err)
return
}
if _, statErr := os.Stat(path); statErr == nil {
fmt.Printf("theme file already exists at %s\n", path)
return
}
_ = os.MkdirAll(filepath.Dir(path), 0755)
defaultContent := `# ~/.config/iris/theme.toml
# iris theme file
# overlay border color
border = "#a277ff"
# accent color for selected marker and icon
accent = "#61ffca"
# muted color for unselected icons
muted = "#6d6a7f"
# default text color
text = "#edecee"
# text color when selected
text_sel = "#ffffff"
# footer shortcut key color
key = "#a277ff"
# matched text color
match = "#61ffca"
# description text color
desc = "#9692a8"
# description text color when selected
desc_sel = "#edecee"
# selected row background
sel_bg = "#3d375e"
# selected tag pill text color
sel_text = "#110f18"
# scroll counter color
scroll_info = "#a277ff"
# inline ghost text color
ghost_text = "#4B4A4C"
# "system" tag background color
sys = "#1e1d28"
# "system" tag selected background color
sys_sel = "#a277ff"
# "history" tag background color
hist = "#1a2d36"
# "history" tag selected background color
hist_sel = "#61ffca"
# "alias" tag background color
alias = "#2a2342"
# "alias" tag selected background color (and alias word color)
alias_sel = "#a277ff"
`
err = os.WriteFile(path, []byte(defaultContent), 0644)
if err != nil {
fmt.Printf("failed to write theme file: %v\n", err)
return
}
fmt.Printf("initialized theme file at %s\n", path)
},
}
func init() {
ThemeCmd.AddCommand(ThemeInitCmd)
rootCmd.AddCommand(ThemeCmd)
}
+39
View File
@@ -0,0 +1,39 @@
package root
import (
"os"
"strings"
"testing"
"github.com/versenilvis/iris/internal/config"
)
func TestThemeInitCommand(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "iris-theme-cmd-test")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// UserConfigDir follows HOME on macOS and XDG_CONFIG_HOME on Unix.
// Override both so the command cannot read or write the user's real theme.
t.Setenv("HOME", tmpDir)
t.Setenv("XDG_CONFIG_HOME", tmpDir)
ThemeInitCmd.Run(ThemeInitCmd, []string{})
themePath, err := config.ThemePath()
if err != nil {
t.Fatalf("failed to get theme path: %v", err)
}
if _, statErr := os.Stat(themePath); statErr != nil {
t.Errorf("expected theme file to be created at %s, but it was not", themePath)
}
content, err := os.ReadFile(themePath)
if err != nil {
t.Fatalf("failed to read theme file: %v", err)
}
if !strings.Contains(string(content), "sel_text = \"#110f18\"") {
t.Error("expected initialized theme to include sel_text = \"#110f18\"")
}
}
+106 -93
View File
@@ -276,8 +276,6 @@ func runWrapper() {
overlay := integration.NewOverlay()
// start background update check (async)
pendingUpdate = startBackgroundUpdateCheck()
updatePrinted := false
@@ -289,10 +287,14 @@ func runWrapper() {
var isCommandActive atomic.Bool
var disableGhostText atomic.Bool
disableGhostText.Store(!config.Get().UI.GhostText)
var renderOverlayFn atomic.Value // holds func()
renderOverlayFn.Store(func() {})
config.AutoDetectConfigChange(func(cfg *config.Config) {
disableGhostText.Store(!cfg.UI.GhostText)
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
})
renderOverlay := func() {}
isExecuting := func() bool {
if isCommandActive.Load() {
// for bash: no preexec/precmd hooks, so fall back to TIOCGPGRP to detect when shell returns
@@ -312,14 +314,16 @@ func runWrapper() {
return pgrp != shellPGID
}
suggestionsEnabled := true
// Shared handler for configured navigation keys (e.g. ctrl+j / ctrl+k).
// Moves the overlay cursor when visible, otherwise opens history/spec
// list and selects the next item in the requested direction.
handleNavKey := func(dir string) {
intercepted = true
userNavigated.Store(true)
if overlay.IsVisible() {
intercepted = true
userNavigated.Store(true)
arrowDir := "down"
if dir == "up" {
arrowDir = "up"
@@ -353,7 +357,12 @@ func runWrapper() {
}
b.WriteString(overlay.Render())
writeStdout([]byte(b.String()))
} else {
} else if suggestionsEnabled {
// hidden-overlay history navigation only when suggestions are enabled;
// otherwise let the navigation keys pass through to the shell
intercepted = true
userNavigated.Store(true)
activeModeMu.Lock()
if activeMode == "" {
activeMode = loadMode()
@@ -443,8 +452,6 @@ func runWrapper() {
}
}()
// listen for suggestion requests from shell scripts via the ipc pipe
go func() {
defer func() {
@@ -565,14 +572,15 @@ func runWrapper() {
cursorOffset = 0
bufferMu.Unlock()
renderOverlay()
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
}
if err := scanner.Err(); err != nil {
logger.Errorf("IPC scanner error: %v", err)
}
}()
suggestionsEnabled := true
activeModeMu.Lock()
activeMode = loadMode()
activeModeMu.Unlock()
@@ -651,7 +659,9 @@ func runWrapper() {
}
SetCurrentAISuggestion(sugg)
if overlay.InjectAISuggestion(*sugg) {
renderOverlay()
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
}
})
}
@@ -693,7 +703,7 @@ func runWrapper() {
writeStdout([]byte(b.String()))
}
renderOverlay = func() {
renderOverlayFn.Store(func() {
renderMu.Lock()
defer renderMu.Unlock()
@@ -718,9 +728,11 @@ func runWrapper() {
renderMu.Unlock()
renderMenuNow()
})
}
})
renderOverlay()
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
// reads from stdin and decides what to forward or intercept
// for most cases, I just handle the already have terminal shortcuts
@@ -746,30 +758,30 @@ func runWrapper() {
b := inputSlice[i]
intercepted = false
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMenu); matched {
intercepted = true
suggestionsEnabled = !suggestionsEnabled
if !suggestionsEnabled {
writeStdout([]byte(overlay.ClearAndDisable()))
} else {
shouldOverlayDraw = true
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMenu); matched {
intercepted = true
suggestionsEnabled = !suggestionsEnabled
if !suggestionsEnabled {
writeStdout([]byte(overlay.ClearAndDisable()))
} else {
shouldOverlayDraw = true
}
i += consumed - 1
continue
}
i += consumed - 1
continue
}
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMode); matched { // ctrl+r: toggle between command specs and command history
i += consumed - 1
intercepted = true
activeModeMu.Lock()
if activeMode == "spec" {
activeMode = "history"
} else {
activeMode = "spec"
}
saveMode(activeMode)
activeModeMu.Unlock()
if userNavigated.Load() {
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMode); matched { // ctrl+r: toggle between command specs and command history
i += consumed - 1
intercepted = true
activeModeMu.Lock()
if activeMode == "spec" {
activeMode = "history"
} else {
activeMode = "spec"
}
saveMode(activeMode)
activeModeMu.Unlock()
if userNavigated.Load() {
bufferMu.Lock()
naiveBuffer = overlay.GetTypedQuery()
cursorOffset = 0
@@ -782,61 +794,60 @@ func runWrapper() {
continue
}
var isNavUp, isNavDown bool
var navConsumed int
if isNavUp, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateUp); !isNavUp {
isNavDown, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateDown)
}
if isNavUp || isNavDown {
arrowDir := "down"
if isNavUp {
arrowDir = "up"
var isNavUp, isNavDown bool
var navConsumed int
if isNavUp, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateUp); !isNavUp {
isNavDown, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateDown)
}
handleNavKey(arrowDir)
i += navConsumed - 1
continue
}
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.SelectSuggestion); matched && config.Get().Keybindings.SelectSuggestion != "" {
if overlay.IsVisible() {
intercepted = true
selected := overlay.GetCurrentCmd()
if selected != "" {
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
if currentMode == "spec" {
s := strings.TrimSpace(selected)
if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") {
selected = s
} else {
selected = s + " "
}
}
bufferMu.Lock()
naiveBuffer = selected
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
overlay.ClearGhostTextState()
userNavigated.Store(false)
writeStdout([]byte(overlay.Render()))
renderOverlay()
if isNavUp || isNavDown {
arrowDir := "down"
if isNavUp {
arrowDir = "up"
}
handleNavKey(arrowDir)
i += navConsumed - 1
continue
}
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.SelectSuggestion); matched && config.Get().Keybindings.SelectSuggestion != "" {
if overlay.IsVisible() {
intercepted = true
selected := overlay.GetCurrentCmd()
if selected != "" {
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
if currentMode == "spec" {
s := strings.TrimSpace(selected)
if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") {
selected = s
} else {
selected = s + " "
}
}
bufferMu.Lock()
naiveBuffer = selected
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
overlay.ClearGhostTextState()
userNavigated.Store(false)
writeStdout([]byte(overlay.Render()))
}
}
// always consume the full binding atomically, even when the overlay is hidden
i += consumed - 1
continue
}
}
if b == 0x0d || b == 0x0a { // enter
intercepted = true
if b == 0x0d || b == 0x0a { // enter
intercepted = true
if b == 0x0d && i+1 < n && inputSlice[i+1] == 0x0a {
if b == 0x0d && i+1 < n && inputSlice[i+1] == 0x0a {
i++ // consume trailing \n in \r\n to prevent matching ctrl+j
}
var selectedCmd string
var shouldAutoExecute bool
if overlay.IsVisible() && (config.Get().Core.AutoExecute || userNavigated.Load()) {
@@ -925,13 +936,13 @@ func runWrapper() {
if !isNavUp {
isNavDown, navConsumed = config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateDown)
}
if isNavUp || isNavDown {
intercepted = true
arrowDir := "down"
if isNavUp {
arrowDir = "up"
}
handleNavKey(arrowDir)
if isNavUp || isNavDown {
intercepted = true
arrowDir := "down"
if isNavUp {
arrowDir = "up"
}
handleNavKey(arrowDir)
i += navConsumed - 1
continue
}
@@ -1073,8 +1084,8 @@ func runWrapper() {
continue
}
if !intercepted {
_, _ = ptmx.Write([]byte{b})
if !intercepted {
_, _ = ptmx.Write([]byte{b})
// we handle line editing keys manually to keep naiveBuffer in sync
// since terminal is in raw mode, we must update our state for every change
switch b {
@@ -1210,7 +1221,9 @@ func runWrapper() {
}
}
if shouldOverlayDraw {
renderOverlay()
if renderer, ok := renderOverlayFn.Load().(func()); ok {
renderer()
}
}
}
}