feat(config): add some more config options (#64)

## Add
Make these things configurable
- Expand aliases
- Suggest hidden files (#59)
- Toggle menu and mode 

```
[ui]
hidden-files = true # or false

[core]
expand-alias = true # or false

[keybindings]
toggle-mode = "ctrl+r"
toggle-menu = "ctrl+space"
```

- Auto reload config on changes: now just changes config and save the
file, IRIS will auto detect and reload in silent
- Added all these in config on init and docs

## Fix
I tested on holding backspace and the ghost text keep glitching so I
fixed it, this is just a small fix
This commit is contained in:
VERSE
2026-07-29 20:43:46 +07:00
committed by GitHub
parent 69fb77910d
commit d6cfb9c909
9 changed files with 271 additions and 81 deletions
+6
View File
@@ -107,14 +107,20 @@ version = 1
shell = "" # "zsh", "bash", "fish", or empty for auto-detection shell = "" # "zsh", "bash", "fish", or empty for auto-detection
mode = "last" # "last", "spec", or "history" mode = "last" # "last", "spec", or "history"
debug = false debug = false
expand-alias = true
[ui] [ui]
style = "modern" # "modern" or "classic" style = "modern" # "modern" or "classic"
ghost-text = true ghost-text = true
hidden-files = false
max-suggestions = 100 max-suggestions = 100
max-height = 15 max-height = 15
nerd-fonts = true nerd-fonts = true
[keybindings]
toggle-mode = "ctrl+r"
toggle-menu = "ctrl+space"
[git] [git]
filter-active-branch = true filter-active-branch = true
deduplicate-branches = true deduplicate-branches = true
+19 -1
View File
@@ -434,6 +434,24 @@ func (o *Overlay) GetGhostText(buffer string, cursorAtEnd bool) string {
return "" return ""
} }
func (o *Overlay) ClearGhostTextState() {
o.mu.Lock()
defer o.mu.Unlock()
o.LastGhostLen = 0
}
func (o *Overlay) HideGhostTextSync() string {
o.mu.Lock()
defer o.mu.Unlock()
if o.LastGhostLen > 0 {
padLen := o.LastGhostLen + 4
res := "\0337" + strings.Repeat(" ", padLen) + "\0338"
o.LastGhostLen = 0
return res
}
return ""
}
func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd bool) string { func (o *Overlay) RenderGhostText(buffer string, userNavigated bool, cursorAtEnd bool) string {
o.mu.Lock() o.mu.Lock()
defer o.mu.Unlock() defer o.mu.Unlock()
@@ -757,7 +775,7 @@ func (o *Overlay) draw() string {
if !isClassic { if !isClassic {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#a277ff")).Bold(true) keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#a277ff")).Bold(true)
tabKey := keyStyle.Render("<Tab>") tabKey := keyStyle.Render("<Tab>")
ctrlRKey := keyStyle.Render("<Ctrl+R>") ctrlRKey := keyStyle.Render(config.FormatKeyName(config.Get().Keybindings.ToggleMode))
acceptText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Accept") acceptText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Accept")
modeText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Mode") modeText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Mode")
footerInfo = fmt.Sprintf(" %s%s • %s%s ", tabKey, acceptText, ctrlRKey, modeText) footerInfo = fmt.Sprintf(" %s%s • %s%s ", tabKey, acceptText, ctrlRKey, modeText)
+45 -5
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
@@ -36,11 +37,13 @@ type CoreConfig struct {
Shell string `toml:"shell"` Shell string `toml:"shell"`
Mode string `toml:"mode"` Mode string `toml:"mode"`
Debug bool `toml:"debug"` Debug bool `toml:"debug"`
ExpandAlias bool `toml:"expand-alias"`
} }
type UIConfig struct { type UIConfig struct {
Style string `toml:"style"` Style string `toml:"style"`
GhostText bool `toml:"ghost-text"` GhostText bool `toml:"ghost-text"`
ShowHiddenFiles bool `toml:"hidden-files"`
MaxSuggestions int `toml:"max-suggestions"` MaxSuggestions int `toml:"max-suggestions"`
MaxHeight int `toml:"max-height"` MaxHeight int `toml:"max-height"`
NerdFonts bool `toml:"nerd-fonts"` NerdFonts bool `toml:"nerd-fonts"`
@@ -57,6 +60,11 @@ type UpdaterConfig struct {
CheckInterval Duration `toml:"check-interval"` CheckInterval Duration `toml:"check-interval"`
} }
type KeybindingsConfig struct {
ToggleMode string `toml:"toggle-mode"`
ToggleMenu string `toml:"toggle-menu"`
}
type SuggestOnEmptyConfig struct { type SuggestOnEmptyConfig struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
DebounceMS int `toml:"debounce_ms"` DebounceMS int `toml:"debounce_ms"`
@@ -106,27 +114,59 @@ type Config struct {
Git GitConfig `toml:"git"` Git GitConfig `toml:"git"`
Updater UpdaterConfig `toml:"updater"` Updater UpdaterConfig `toml:"updater"`
AI AIConfig `toml:"ai"` AI AIConfig `toml:"ai"`
Keybindings KeybindingsConfig `toml:"keybindings"`
} }
var ( var (
activeConfig *Config activeConfig atomic.Pointer[Config]
once sync.Once once sync.Once
) )
func Get() *Config { func Get() *Config {
once.Do(func() { once.Do(func() {
if activeConfig == nil { if activeConfig.Load() == nil {
activeConfig = DefaultConfig() activeConfig.Store(DefaultConfig())
} }
}) })
return activeConfig return activeConfig.Load()
} }
func Init(cfg *Config) { func Init(cfg *Config) {
activeConfig = cfg activeConfig.Store(cfg)
once.Do(func() {}) once.Do(func() {})
} }
func AutoDetectConfigChange(onReload func(cfg *Config)) {
path, err := ConfigPath()
if err != nil {
return
}
go func() {
var lastMod time.Time
if info, err := os.Stat(path); err == nil {
lastMod = info.ModTime()
}
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for range ticker.C {
info, err := os.Stat(path)
if err == nil {
if !lastMod.IsZero() && info.ModTime().After(lastMod) {
lastMod = info.ModTime()
if newCfg, err := Load(); err == nil {
Init(newCfg)
if onReload != nil {
onReload(newCfg)
}
}
} else if lastMod.IsZero() {
lastMod = info.ModTime()
}
}
}
}()
}
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := DefaultConfig() cfg := DefaultConfig()
+6
View File
@@ -9,10 +9,12 @@ func DefaultConfig() *Config {
Shell: "", Shell: "",
Mode: "last", Mode: "last",
Debug: false, Debug: false,
ExpandAlias: true,
}, },
UI: UIConfig{ UI: UIConfig{
Style: "modern", Style: "modern",
GhostText: true, GhostText: true,
ShowHiddenFiles: false,
MaxSuggestions: 100, MaxSuggestions: 100,
MaxHeight: 15, MaxHeight: 15,
NerdFonts: true, NerdFonts: true,
@@ -38,6 +40,10 @@ func DefaultConfig() *Config {
MinIntervalMS: 5000, MinIntervalMS: 5000,
}, },
}, },
Keybindings: KeybindingsConfig{
ToggleMode: "ctrl+r",
ToggleMenu: "shift+tab",
},
} }
} }
+64
View File
@@ -0,0 +1,64 @@
package config
import (
"strings"
)
// MatchKey matches a sequence of terminal bytes against a configured key string.
// Key strings can be e.g. "ctrl+r", "tab", "shift+tab", "right".
// Returns matched=true if the byte sequence matches the configured key, and consumed=number of bytes to consume.
func MatchKey(input []byte, expected string) (matched bool, consumed int) {
if len(input) == 0 {
return false, 0
}
expected = strings.ToLower(strings.TrimSpace(expected))
if strings.HasPrefix(expected, "ctrl+") && len(expected) == 6 {
char := expected[5]
if char >= 'a' && char <= 'z' {
targetByte := char - 'a' + 1
if input[0] == targetByte {
return true, 1
}
}
}
switch expected {
case "ctrl+space":
if input[0] == 0x00 {
return true, 1
}
case "tab":
if input[0] == 0x09 {
return true, 1
}
case "shift+tab":
// typically \033[Z
if len(input) >= 3 && input[0] == 0x1b && input[1] == '[' && input[2] == 'Z' {
return true, 3
}
case "right":
// typically \033[C or \033OC
if len(input) >= 3 && input[0] == 0x1b && (input[1] == '[' || input[1] == 'O') && input[2] == 'C' {
return true, 3
}
case "enter":
if input[0] == 0x0d || input[0] == 0x0a {
return true, 1
}
}
return false, 0
}
// FormatKeyName takes a config key string like "ctrl+r" and formats it for UI display, e.g. "<Ctrl+R>".
func FormatKeyName(key string) string {
parts := strings.Split(key, "+")
for i, p := range parts {
if len(p) > 0 {
parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:])
}
}
return "<" + strings.Join(parts, "+") + ">"
}
+13
View File
@@ -50,6 +50,9 @@ mode = "last"
# enable debug logging # enable debug logging
debug = false debug = false
# auto expand alias when typing space
expand-alias = true
[ui] [ui]
# visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) # visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons)
style = "modern" style = "modern"
@@ -60,12 +63,22 @@ nerd-fonts = true
# enable inline ghost text # enable inline ghost text
ghost-text = true ghost-text = true
# show hidden files (starting with dot) in file generator
hidden-files = false
# maximum suggestions to display # maximum suggestions to display
max-suggestions = 100 max-suggestions = 100
# maximum height of the overlay # maximum height of the overlay
max-height = 15 max-height = 15
[keybindings]
# toggle between spec and history mode
toggle-mode = "ctrl+r"
# toggle menu visibility
toggle-menu = "ctrl+space"
[git] [git]
# hide current branch in checkout/switch list # hide current branch in checkout/switch list
filter-active-branch = true filter-active-branch = true
+12
View File
@@ -186,6 +186,9 @@ mode = "last"
# enable debug logging # enable debug logging
debug = false debug = false
# automatically expand aliases on space
expand-alias = true
[ui] [ui]
# visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) # visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons)
style = "modern" style = "modern"
@@ -193,6 +196,9 @@ style = "modern"
# enable Nerd Fonts icons in overlay menu # enable Nerd Fonts icons in overlay menu
nerd-fonts = true nerd-fonts = true
# show hidden files with dot prefix
hidden-files = false
# enable inline ghost text # enable inline ghost text
ghost-text = true ghost-text = true
@@ -218,6 +224,12 @@ channel = "stable"
# interval between update checks, e.g. "24h", "6h", "30m" # interval between update checks, e.g. "24h", "6h", "30m"
check-interval = "24h" check-interval = "24h"
[keybindings]
toggle_mode = "ctrl+r"
toggle_menu = "shift+tab"
select_suggestion = "tab"
accept_suggestion = "right"
` `
if errWrite := os.WriteFile(path, []byte(defaultContent), 0644); errWrite == nil { if errWrite := os.WriteFile(path, []byte(defaultContent), 0644); errWrite == nil {
fmt.Printf("✓ Initialized default config file at %s\n", path) fmt.Printf("✓ Initialized default config file at %s\n", path)
+78 -49
View File
@@ -247,6 +247,8 @@ func runWrapper() {
overlay := integration.NewOverlay() overlay := integration.NewOverlay()
// start background update check (async) // start background update check (async)
pendingUpdate = startBackgroundUpdateCheck() pendingUpdate = startBackgroundUpdateCheck()
updatePrinted := false updatePrinted := false
@@ -256,6 +258,12 @@ func runWrapper() {
shellPGID = spec.ShellPID shellPGID = spec.ShellPID
} }
var isCommandActive atomic.Bool var isCommandActive atomic.Bool
var disableGhostText atomic.Bool
disableGhostText.Store(!config.Get().UI.GhostText)
config.AutoDetectConfigChange(func(cfg *config.Config) {
disableGhostText.Store(!cfg.UI.GhostText)
})
var renderOverlay func()
isExecuting := func() bool { isExecuting := func() bool {
if isCommandActive.Load() { if isCommandActive.Load() {
return true return true
@@ -311,10 +319,6 @@ func runWrapper() {
} }
}() }()
var disableGhostText atomic.Bool
disableGhostText.Store(!config.Get().UI.GhostText)
var renderOverlay func()
// listen for suggestion requests from shell scripts via the ipc pipe // listen for suggestion requests from shell scripts via the ipc pipe
go func() { go func() {
defer func() { defer func() {
@@ -576,9 +580,9 @@ func runWrapper() {
} }
if renderTimer != nil { if renderTimer != nil {
renderTimer.Stop() return
} }
renderTimer = time.AfterFunc(25*time.Millisecond, func() { renderTimer = time.AfterFunc(20*time.Millisecond, func() {
renderMu.Lock() renderMu.Lock()
renderTimer = nil renderTimer = nil
renderMu.Unlock() renderMu.Unlock()
@@ -614,6 +618,21 @@ func runWrapper() {
b := inputSlice[i] b := inputSlice[i]
intercepted := false intercepted := false
if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.ToggleMenu); matched {
intercepted = true
suggestionsEnabled = !suggestionsEnabled
logger.Debugf("Intercepted ToggleMenu, suggestionsEnabled=%v", suggestionsEnabled)
if !suggestionsEnabled {
writeStdout([]byte(overlay.ClearAndDisable()))
} else {
shouldOverlayDraw = true
}
i += consumed - 1
continue
}
if b == '\033' { if b == '\033' {
// check for bracketed paste start/end // check for bracketed paste start/end
if i+5 < n && inputSlice[i+1] == '[' && inputSlice[i+2] == '2' && inputSlice[i+3] == '0' { if i+5 < n && inputSlice[i+1] == '[' && inputSlice[i+2] == '2' && inputSlice[i+3] == '0' {
@@ -628,20 +647,6 @@ func runWrapper() {
} }
// handle escape sequences like arrow keys and functional shortcuts // handle escape sequences like arrow keys and functional shortcuts
if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') { if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') {
// shift tab: hide/unhide menu dropdown
if inputSlice[i+1] == '[' && inputSlice[i+2] == 'Z' {
intercepted = true
suggestionsEnabled = !suggestionsEnabled
logger.Debugf("Intercepted Shift+Tab, suggestionsEnabled=%v", suggestionsEnabled)
if !suggestionsEnabled {
writeStdout([]byte(overlay.ClearAndDisable()))
} else {
shouldOverlayDraw = true
}
i += 2
continue
}
if overlay.IsVisible() && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { if overlay.IsVisible() && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') {
intercepted = true intercepted = true
userNavigated.Store(true) userNavigated.Store(true)
@@ -722,24 +727,6 @@ func runWrapper() {
} }
i += 2 i += 2
continue continue
} else if !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow
bufferMu.Lock()
atEnd := (cursorOffset == 0)
ghostText := overlay.GetGhostText(naiveBuffer, atEnd)
bufferMu.Unlock()
if len(ghostText) > 0 {
intercepted = true
logger.Debugf("Intercepted Right Arrow (accepted ghost text: %q)", ghostText)
bufferMu.Lock()
naiveBuffer += ghostText
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write([]byte(ghostText))
shouldOverlayDraw = true
i += 2
continue
}
} }
} }
@@ -775,6 +762,29 @@ func runWrapper() {
i += 2 i += 2
continue continue
} }
bufferMu.Lock()
atEnd := (cursorOffset == 0)
ghostText := ""
if !disableGhostText.Load() {
ghostText = overlay.GetGhostText(naiveBuffer, atEnd)
}
bufferMu.Unlock()
if len(ghostText) > 0 {
intercepted = true
logger.Debugf("Intercepted Right Arrow (accepted ghost text: %q)", ghostText)
bufferMu.Lock()
naiveBuffer += ghostText
cursorOffset = 0
bufferMu.Unlock()
overlay.ClearGhostTextState()
_, _ = ptmx.Write([]byte(ghostText))
shouldOverlayDraw = true
i += 2
continue
}
bufferMu.Lock() bufferMu.Lock()
if naiveBuffer != "" || overlay.IsVisible() { if naiveBuffer != "" || overlay.IsVisible() {
cursorOffset-- cursorOffset--
@@ -813,7 +823,8 @@ func runWrapper() {
continue continue
} }
if b == 0x12 { // ctrl+r: toggle between command specs and command history 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 intercepted = true
activeModeMu.Lock() activeModeMu.Lock()
if activeMode == "spec" { if activeMode == "spec" {
@@ -869,12 +880,33 @@ func runWrapper() {
} }
renderMu.Unlock() renderMu.Unlock()
if cmdToSubmit == "" {
bufferMu.Lock()
cmdToSubmit = naiveBuffer
bufferMu.Unlock()
}
if strings.TrimSpace(cmdToSubmit) == "iris reload" {
if newCfg, err := config.Load(); err == nil {
config.Init(newCfg)
disableGhostText.Store(!newCfg.UI.GhostText)
}
// Clear the shell line with Ctrl+U, then echo message, then Enter
msg := "echo -e '\\033[32m✓ Iris configuration reloaded successfully.\\033[0m'\r"
_, _ = ptmx.Write(append([]byte{0x15}, []byte(msg)...))
bufferMu.Lock()
naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
disableGhostText.Store(false)
shouldOverlayDraw = false
userNavigated.Store(false)
continue
}
isCommandActive.Store(true) isCommandActive.Store(true)
_, _ = ptmx.Write([]byte{b}) _, _ = ptmx.Write([]byte{b})
bufferMu.Lock() bufferMu.Lock()
if cmdToSubmit == "" {
cmdToSubmit = naiveBuffer
}
lastSubmittedCommand = strings.TrimSpace(cmdToSubmit) lastSubmittedCommand = strings.TrimSpace(cmdToSubmit)
naiveBuffer = "" naiveBuffer = ""
cursorOffset = 0 cursorOffset = 0
@@ -930,12 +962,8 @@ func runWrapper() {
bufferMu.Unlock() bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, selected...)) _, _ = ptmx.Write(append([]byte{0x15}, selected...))
overlay.ResetCursor()
overlay.ResetCursor() // this prevents when you tab, it switches between suggestions non-stop shouldOverlayDraw = true
shouldOverlayDraw = true // <- rerender after tab to choose, if you set to false,
// when you press tab continually, it will print all folder from menu suggestions
// and make the cursor jump to next line
userNavigated.Store(false) userNavigated.Store(false)
} }
continue continue
@@ -1034,8 +1062,9 @@ func runWrapper() {
// track normal printable characters in the buffer for matching // track normal printable characters in the buffer for matching
if b >= 32 && b <= 126 { if b >= 32 && b <= 126 {
// expand alias on space, but only when typing manually (not pasting) // expand alias on space, but only when typing manually (not pasting)
// and only if expand-alias configuration is enabled
bufferMu.Lock() bufferMu.Lock()
isSpaceAlias := !inBracketedPaste && b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ") isSpaceAlias := config.Get().Core.ExpandAlias && !inBracketedPaste && b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ")
var target string var target string
var ok bool var ok bool
if isSpaceAlias { if isSpaceAlias {
+5 -3
View File
@@ -6,6 +6,8 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"github.com/versenilvis/iris/internal/config"
) )
// for tracking current working dir // for tracking current working dir
@@ -34,7 +36,7 @@ func GetCWD() string {
// dir = "src/", filePrefix = "mai" // dir = "src/", filePrefix = "mai"
// search for which matches "mai" case, and show it (whici is both main.go and main.py) // search for which matches "mai" case, and show it (whici is both main.go and main.py)
// but if you type "go run src/mai" -> it will only shows suggestion about src/main.go // but if you type "go run src/mai" -> it will only shows suggestion about src/main.go
// NOTE: it will skip hidden file start with dot prefix // NOTE: it will skip hidden file start with dot prefix unless configured otherwise
func FileGenerator(filters ...string) GeneratorFunc { func FileGenerator(filters ...string) GeneratorFunc {
dirOnly := false dirOnly := false
filterSet := make(map[string]bool) filterSet := make(map[string]bool)
@@ -82,8 +84,8 @@ func FileGenerator(filters ...string) GeneratorFunc {
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
// skip hidden files // skip hidden files if not configured to show them
if strings.HasPrefix(name, ".") { if !config.Get().UI.ShowHiddenFiles && strings.HasPrefix(entry.Name(), ".") {
continue continue
} }