From d6cfb9c90981db34a188744d95f4352261f33332 Mon Sep 17 00:00:00 2001 From: VERSE Date: Wed, 29 Jul 2026 20:43:46 +0700 Subject: [PATCH] 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 --- docs/README.md | 6 ++ integration/overlay.go | 20 +++++- internal/config/config.go | 78 ++++++++++++++++------ internal/config/defaults.go | 24 ++++--- internal/config/keys.go | 64 ++++++++++++++++++ root/config_cmd.go | 13 ++++ root/init.go | 12 ++++ root/wrapper.go | 127 ++++++++++++++++++++++-------------- spec/filegen.go | 8 ++- 9 files changed, 271 insertions(+), 81 deletions(-) create mode 100644 internal/config/keys.go diff --git a/docs/README.md b/docs/README.md index 98f2972..8497806 100644 --- a/docs/README.md +++ b/docs/README.md @@ -107,14 +107,20 @@ version = 1 shell = "" # "zsh", "bash", "fish", or empty for auto-detection mode = "last" # "last", "spec", or "history" debug = false +expand-alias = true [ui] style = "modern" # "modern" or "classic" ghost-text = true +hidden-files = false max-suggestions = 100 max-height = 15 nerd-fonts = true +[keybindings] +toggle-mode = "ctrl+r" +toggle-menu = "ctrl+space" + [git] filter-active-branch = true deduplicate-branches = true diff --git a/integration/overlay.go b/integration/overlay.go index a53d762..d64005f 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -434,6 +434,24 @@ func (o *Overlay) GetGhostText(buffer string, cursorAtEnd bool) string { 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 { o.mu.Lock() defer o.mu.Unlock() @@ -757,7 +775,7 @@ func (o *Overlay) draw() string { if !isClassic { keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#a277ff")).Bold(true) tabKey := keyStyle.Render("") - ctrlRKey := keyStyle.Render("") + ctrlRKey := keyStyle.Render(config.FormatKeyName(config.Get().Keybindings.ToggleMode)) acceptText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Accept") modeText := lipgloss.NewStyle().Foreground(t.ScrollInfo).Render(" Mode") footerInfo = fmt.Sprintf(" %s%s • %s%s ", tabKey, acceptText, ctrlRKey, modeText) diff --git a/internal/config/config.go b/internal/config/config.go index 70a01e4..ae21f0e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sync" + "sync/atomic" "time" "github.com/BurntSushi/toml" @@ -32,18 +33,20 @@ func (d Duration) MarshalText() ([]byte, error) { } type CoreConfig struct { - Version int `toml:"version"` - Shell string `toml:"shell"` - Mode string `toml:"mode"` - Debug bool `toml:"debug"` + Version int `toml:"version"` + Shell string `toml:"shell"` + Mode string `toml:"mode"` + Debug bool `toml:"debug"` + ExpandAlias bool `toml:"expand-alias"` } type UIConfig struct { - Style string `toml:"style"` - GhostText bool `toml:"ghost-text"` - MaxSuggestions int `toml:"max-suggestions"` - MaxHeight int `toml:"max-height"` - NerdFonts bool `toml:"nerd-fonts"` + Style string `toml:"style"` + GhostText bool `toml:"ghost-text"` + ShowHiddenFiles bool `toml:"hidden-files"` + MaxSuggestions int `toml:"max-suggestions"` + MaxHeight int `toml:"max-height"` + NerdFonts bool `toml:"nerd-fonts"` } type GitConfig struct { @@ -57,6 +60,11 @@ type UpdaterConfig struct { CheckInterval Duration `toml:"check-interval"` } +type KeybindingsConfig struct { + ToggleMode string `toml:"toggle-mode"` + ToggleMenu string `toml:"toggle-menu"` +} + type SuggestOnEmptyConfig struct { Enabled bool `toml:"enabled"` DebounceMS int `toml:"debounce_ms"` @@ -101,32 +109,64 @@ func (p *ProviderConfig) GetAPIKey() string { } type Config struct { - Core CoreConfig `toml:"core"` - UI UIConfig `toml:"ui"` - Git GitConfig `toml:"git"` - Updater UpdaterConfig `toml:"updater"` - AI AIConfig `toml:"ai"` + Core CoreConfig `toml:"core"` + UI UIConfig `toml:"ui"` + Git GitConfig `toml:"git"` + Updater UpdaterConfig `toml:"updater"` + AI AIConfig `toml:"ai"` + Keybindings KeybindingsConfig `toml:"keybindings"` } var ( - activeConfig *Config + activeConfig atomic.Pointer[Config] once sync.Once ) func Get() *Config { once.Do(func() { - if activeConfig == nil { - activeConfig = DefaultConfig() + if activeConfig.Load() == nil { + activeConfig.Store(DefaultConfig()) } }) - return activeConfig + return activeConfig.Load() } func Init(cfg *Config) { - activeConfig = cfg + activeConfig.Store(cfg) 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) { cfg := DefaultConfig() diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 1d8bdc1..27e651f 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -5,17 +5,19 @@ import "time" func DefaultConfig() *Config { return &Config{ Core: CoreConfig{ - Version: 1, - Shell: "", - Mode: "last", - Debug: false, + Version: 1, + Shell: "", + Mode: "last", + Debug: false, + ExpandAlias: true, }, UI: UIConfig{ - Style: "modern", - GhostText: true, - MaxSuggestions: 100, - MaxHeight: 15, - NerdFonts: true, + Style: "modern", + GhostText: true, + ShowHiddenFiles: false, + MaxSuggestions: 100, + MaxHeight: 15, + NerdFonts: true, }, Git: GitConfig{ FilterActiveBranch: true, @@ -38,6 +40,10 @@ func DefaultConfig() *Config { MinIntervalMS: 5000, }, }, + Keybindings: KeybindingsConfig{ + ToggleMode: "ctrl+r", + ToggleMenu: "shift+tab", + }, } } diff --git a/internal/config/keys.go b/internal/config/keys.go new file mode 100644 index 0000000..e4162a6 --- /dev/null +++ b/internal/config/keys.go @@ -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. "". +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, "+") + ">" +} diff --git a/root/config_cmd.go b/root/config_cmd.go index 929021d..491c29f 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -50,6 +50,9 @@ mode = "last" # enable debug logging debug = false +# auto expand alias when typing space +expand-alias = true + [ui] # visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) style = "modern" @@ -60,12 +63,22 @@ nerd-fonts = true # enable inline ghost text ghost-text = true +# show hidden files (starting with dot) in file generator +hidden-files = false + # maximum suggestions to display max-suggestions = 100 # maximum height of the overlay max-height = 15 +[keybindings] +# toggle between spec and history mode +toggle-mode = "ctrl+r" + +# toggle menu visibility +toggle-menu = "ctrl+space" + [git] # hide current branch in checkout/switch list filter-active-branch = true diff --git a/root/init.go b/root/init.go index d62bafc..d2a4837 100644 --- a/root/init.go +++ b/root/init.go @@ -186,6 +186,9 @@ mode = "last" # enable debug logging debug = false +# automatically expand aliases on space +expand-alias = true + [ui] # visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) style = "modern" @@ -193,6 +196,9 @@ style = "modern" # enable Nerd Fonts icons in overlay menu nerd-fonts = true +# show hidden files with dot prefix +hidden-files = false + # enable inline ghost text ghost-text = true @@ -218,6 +224,12 @@ channel = "stable" # interval between update checks, e.g. "24h", "6h", "30m" 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 { fmt.Printf("✓ Initialized default config file at %s\n", path) diff --git a/root/wrapper.go b/root/wrapper.go index 209111d..d2a268c 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -247,6 +247,8 @@ func runWrapper() { overlay := integration.NewOverlay() + + // start background update check (async) pendingUpdate = startBackgroundUpdateCheck() updatePrinted := false @@ -256,6 +258,12 @@ func runWrapper() { shellPGID = spec.ShellPID } 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 { if isCommandActive.Load() { 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 go func() { defer func() { @@ -576,9 +580,9 @@ func runWrapper() { } if renderTimer != nil { - renderTimer.Stop() + return } - renderTimer = time.AfterFunc(25*time.Millisecond, func() { + renderTimer = time.AfterFunc(20*time.Millisecond, func() { renderMu.Lock() renderTimer = nil renderMu.Unlock() @@ -614,6 +618,21 @@ func runWrapper() { b := inputSlice[i] 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' { // check for bracketed paste start/end 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 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') { intercepted = true userNavigated.Store(true) @@ -722,24 +727,6 @@ func runWrapper() { } i += 2 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 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() if naiveBuffer != "" || overlay.IsVisible() { cursorOffset-- @@ -813,7 +823,8 @@ func runWrapper() { 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 activeModeMu.Lock() if activeMode == "spec" { @@ -869,12 +880,33 @@ func runWrapper() { } 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) _, _ = ptmx.Write([]byte{b}) bufferMu.Lock() - if cmdToSubmit == "" { - cmdToSubmit = naiveBuffer - } lastSubmittedCommand = strings.TrimSpace(cmdToSubmit) naiveBuffer = "" cursorOffset = 0 @@ -930,12 +962,8 @@ func runWrapper() { bufferMu.Unlock() _, _ = ptmx.Write(append([]byte{0x15}, selected...)) - - overlay.ResetCursor() // this prevents when you tab, it switches between suggestions non-stop - - 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 + overlay.ResetCursor() + shouldOverlayDraw = true userNavigated.Store(false) } continue @@ -1034,8 +1062,9 @@ func runWrapper() { // track normal printable characters in the buffer for matching if b >= 32 && b <= 126 { // expand alias on space, but only when typing manually (not pasting) + // and only if expand-alias configuration is enabled bufferMu.Lock() - isSpaceAlias := !inBracketedPaste && b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ") + isSpaceAlias := config.Get().Core.ExpandAlias && !inBracketedPaste && b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ") var target string var ok bool if isSpaceAlias { diff --git a/spec/filegen.go b/spec/filegen.go index 3b7e156..26f51e0 100644 --- a/spec/filegen.go +++ b/spec/filegen.go @@ -6,6 +6,8 @@ import ( "path/filepath" "sort" "strings" + + "github.com/versenilvis/iris/internal/config" ) // for tracking current working dir @@ -34,7 +36,7 @@ func GetCWD() string { // dir = "src/", filePrefix = "mai" // 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 -// 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 { dirOnly := false filterSet := make(map[string]bool) @@ -82,8 +84,8 @@ func FileGenerator(filters ...string) GeneratorFunc { for _, entry := range entries { name := entry.Name() - // skip hidden files - if strings.HasPrefix(name, ".") { + // skip hidden files if not configured to show them + if !config.Get().UI.ShowHiddenFiles && strings.HasPrefix(entry.Name(), ".") { continue }