From a885c7462abaacb44607df11bbf6fa3b37c4ccd3 Mon Sep 17 00:00:00 2001 From: VERSE Date: Fri, 31 Jul 2026 10:49:10 +0700 Subject: [PATCH] feat(config): ui width, auto exec, selection and navigation configs (#80) closes #67 So I made an auto-exec confg `auto-exec = true` (e32c0ba6ed69f3c764353e58b8fbacf160f37d5a) which means: - If you want your command in the prompt to be executed right away, you can set this = false (for example, you are typing `nvim ~/.conf` but IRIS is suggesting `nvim ~/.config`, it will ignore the suggestion and exec what you are typing, this is current default behavior) - If you want to auto execute full command which is being suggested, you can set this = true (this is the opposite of above, when you are typing `nvim ~/.conf` but IRIS is suggesting `nvim ~/.config`, it will auto exec `nvim ~/.config` instead, but sometime you want to exec what you are typing instead of the suggestion right?, just press esc or shift tab to hide the menu and it will) --- Select keybinding to replace tab (0dc01cba8c610e8d6fd8c50f6a71bdcc061a098f) `select = "ctrl+y"` Some users may love nvim keybinding or emacs, so I bring navigation keybinding to be configurable (0dc01cba8c610e8d6fd8c50f6a71bdcc061a098f) ``` navigate-up = "ctrl+k" navigate-down = "ctrl+j" ``` --- I also added a option for UI width (dfa3b810a7be406fe4652b3886020f37bb166811) `max-width = 80` - If you configure `max-width` to be larger than the actual width of the current terminal, Iris will automatically "compress" the window to match the terminal's width (meaning it will never overflow and break the interface) - If you configure `max-width` too small (e.g 10), the interface will have cramped text, making it look very bad. So, I've set a minimum safe min limit of 40. If the configured value is smaller than this, Iris will automatically use 40 Small note: If you set `max-width = 0` (or leave it blank), it will automatically use Iris's default standard size of 76. SO you can confidently customize it without fear of breaking the UI (I hope so lmao) --- Added a fallback to ensure Iris never crashes or loses shortcuts if the user accidentally deletes keybinding configuration lines or intentionally assigns empty strings " " (f682c9bf623f3ed46ad0763221145604dbd91b07) Fix typo `toggle_mode` to `toggle-mode` (b39be6673e552735fa65a6c9807510c83b4d9cdd) if you have a better config design, please tell me, I always listen to your idea --- README.md | 13 +- integration/overlay.go | 13 +- internal/config/config.go | 26 ++- internal/config/defaults.go | 9 +- internal/config/keys.go | 16 +- root/init.go | 15 +- root/wrapper.go | 437 +++++++++++++++++++----------------- 7 files changed, 303 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index a2dc74b..e776f77 100644 --- a/README.md +++ b/README.md @@ -155,23 +155,28 @@ iris config show ```toml [core] version = 1 -shell = "" # "zsh", "bash", "fish", or empty for auto-detection +shell = "" # "zsh", "bash", "fish", or empty for auto-detection shell-login = false # run the selected shell as a login shell; can also be enabled with iris --shell-login -mode = "last" # "last", "spec", or "history" +mode = "last" # "last", "spec", or "history" debug = false expand-alias = true +auto-execute = false [ui] -style = "modern" # "modern" or "classic" +style = "modern" # "modern" or "classic" ghost-text = true hidden-files = false max-suggestions = 100 max-height = 15 +max-width = 0 nerd-fonts = true [keybindings] toggle-mode = "ctrl+r" -toggle-menu = "ctrl+space" +toggle-menu = "shift+tab" +select = "tab" +navigate-up = "up" +navigate-down = "down" [git] filter-active-branch = true diff --git a/integration/overlay.go b/integration/overlay.go index d64005f..5be37b0 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -15,7 +15,6 @@ import ( ) const ( - boxWidth = 76 // total visual width, corners included maxItems = 6 ) @@ -569,6 +568,18 @@ func (o *Overlay) draw() string { if err != nil || width <= 0 { width = 120 } + + boxWidth := config.Get().UI.MaxWidth + if boxWidth <= 0 { + boxWidth = 76 // Default if 0 + } + if boxWidth > width { + boxWidth = width // Responsive: don't overflow + } + if boxWidth < 40 { + boxWidth = 40 // Minimum safe width + } + if targetCol+boxWidth > width { targetCol = width - boxWidth } diff --git a/internal/config/config.go b/internal/config/config.go index e602aa4..e331f44 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,7 @@ type CoreConfig struct { Mode string `toml:"mode"` Debug bool `toml:"debug"` ExpandAlias bool `toml:"expand-alias"` + AutoExecute bool `toml:"auto-execute"` } type UIConfig struct { @@ -47,6 +48,7 @@ type UIConfig struct { ShowHiddenFiles bool `toml:"hidden-files"` MaxSuggestions int `toml:"max-suggestions"` MaxHeight int `toml:"max-height"` + MaxWidth int `toml:"max-width"` NerdFonts bool `toml:"nerd-fonts"` } @@ -62,8 +64,11 @@ type UpdaterConfig struct { } type KeybindingsConfig struct { - ToggleMode string `toml:"toggle-mode"` - ToggleMenu string `toml:"toggle-menu"` + ToggleMode string `toml:"toggle-mode"` + ToggleMenu string `toml:"toggle-menu"` + SelectSuggestion string `toml:"select"` + NavigateUp string `toml:"navigate-up"` + NavigateDown string `toml:"navigate-down"` } type SuggestOnEmptyConfig struct { @@ -186,6 +191,23 @@ func Load() (*Config, error) { applyEnv(cfg) + // fallback for empty keybindings + if cfg.Keybindings.ToggleMode == "" { + cfg.Keybindings.ToggleMode = "ctrl+r" + } + if cfg.Keybindings.ToggleMenu == "" { + cfg.Keybindings.ToggleMenu = "shift+tab" + } + if cfg.Keybindings.SelectSuggestion == "" { + cfg.Keybindings.SelectSuggestion = "tab" + } + if cfg.Keybindings.NavigateUp == "" { + cfg.Keybindings.NavigateUp = "up" + } + if cfg.Keybindings.NavigateDown == "" { + cfg.Keybindings.NavigateDown = "down" + } + if err := validate(cfg); err != nil { return cfg, fmt.Errorf("config: invalid value: %w", err) } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 4b6c40d..076c9ce 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -11,6 +11,7 @@ func DefaultConfig() *Config { Mode: "last", Debug: false, ExpandAlias: true, + AutoExecute: false, }, UI: UIConfig{ Style: "modern", @@ -18,6 +19,7 @@ func DefaultConfig() *Config { ShowHiddenFiles: false, MaxSuggestions: 100, MaxHeight: 15, + MaxWidth: 0, // 0 means no limit, fallback to terminal width NerdFonts: true, }, Git: GitConfig{ @@ -42,8 +44,11 @@ func DefaultConfig() *Config { }, }, Keybindings: KeybindingsConfig{ - ToggleMode: "ctrl+r", - ToggleMenu: "shift+tab", + ToggleMode: "ctrl+r", + ToggleMenu: "shift+tab", + SelectSuggestion: "tab", + NavigateUp: "up", + NavigateDown: "down", }, } } diff --git a/internal/config/keys.go b/internal/config/keys.go index e4162a6..7e1e966 100644 --- a/internal/config/keys.go +++ b/internal/config/keys.go @@ -13,6 +13,8 @@ func MatchKey(input []byte, expected string) (matched bool, consumed int) { } expected = strings.ToLower(strings.TrimSpace(expected)) + expected = strings.TrimPrefix(expected, "<") + expected = strings.TrimSuffix(expected, ">") if strings.HasPrefix(expected, "ctrl+") && len(expected) == 6 { char := expected[5] @@ -38,12 +40,24 @@ func MatchKey(input []byte, expected string) (matched bool, consumed int) { if len(input) >= 3 && input[0] == 0x1b && input[1] == '[' && input[2] == 'Z' { return true, 3 } + case "up": + if len(input) >= 3 && input[0] == 0x1b && (input[1] == '[' || input[1] == 'O') && input[2] == 'A' { + return true, 3 + } + case "down": + if len(input) >= 3 && input[0] == 0x1b && (input[1] == '[' || input[1] == 'O') && input[2] == 'B' { + 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": + case "left": + if len(input) >= 3 && input[0] == 0x1b && (input[1] == '[' || input[1] == 'O') && input[2] == 'D' { + return true, 3 + } + case "enter", "cr", "return": if input[0] == 0x0d || input[0] == 0x0a { return true, 1 } diff --git a/root/init.go b/root/init.go index 5414260..3ba0800 100644 --- a/root/init.go +++ b/root/init.go @@ -235,6 +235,9 @@ debug = false # automatically expand aliases on space expand-alias = true +# automatically execute command after accepting suggestion +auto-execute = false + [ui] # visual style: "modern" (icons, category pills, shortcut footer) or "classic" (minimalist, centered number, no icons) style = "modern" @@ -254,6 +257,9 @@ max-suggestions = 100 # maximum height of the overlay max-height = 15 +# maximum width of the overlay (0 = responsive to terminal) +max-width = 0 + [git] # hide current branch in checkout/switch list filter-active-branch = true @@ -272,10 +278,11 @@ channel = "stable" check-interval = "24h" [keybindings] -toggle_mode = "ctrl+r" -toggle_menu = "shift+tab" -select_suggestion = "tab" -accept_suggestion = "right" +toggle-mode = "ctrl+r" +toggle-menu = "shift+tab" +select = "tab" +navigate-up = "up" +navigate-down = "down" ` 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 ea67c9c..5c8c500 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -674,7 +674,227 @@ func runWrapper() { 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() + logger.Debugf("Intercepted Ctrl+R, toggled mode to %q", activeMode) + if userNavigated.Load() { + bufferMu.Lock() + naiveBuffer = overlay.GetTypedQuery() + cursorOffset = 0 + bufferMu.Unlock() + _, _ = ptmx.Write(append([]byte{0x15}, overlay.GetTypedQuery()...)) + } + userNavigated.Store(false) + overlay.Show() + shouldOverlayDraw = true + 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 { + if overlay.IsVisible() { + intercepted = true + userNavigated.Store(true) + + arrowDir := "down" + if isNavUp { + arrowDir = "up" + } + moved, selectedCmd := overlay.MoveCursor(arrowDir) + if !moved { + i += navConsumed - 1 + continue + } + + bufferMu.Lock() + activeModeMu.RLock() + isHistMode := activeMode == "history" + activeModeMu.RUnlock() + var toWrite []byte + if isHistMode && selectedCmd != "" { + naiveBuffer = selectedCmd + cursorOffset = 0 + toWrite = append([]byte{0x15}, selectedCmd...) + } + bufCopy := naiveBuffer + offsetCopy := cursorOffset + bufferMu.Unlock() + + if len(toWrite) > 0 { + _, _ = ptmx.Write(toWrite) + } + + var b strings.Builder + if !disableGhostText.Load() { + b.WriteString(overlay.RenderGhostText(bufCopy, true, offsetCopy == 0)) + } + b.WriteString(overlay.Render()) + writeStdout([]byte(b.String())) + + i += navConsumed - 1 + continue + } else if naiveBuffer == "" { + // up/down arrow on empty prompt + intercepted = true + activeModeMu.Lock() + activeMode = "history" + saveMode(activeMode) + activeModeMu.Unlock() + + activeModeMu.RLock() + currentMode := activeMode + activeModeMu.RUnlock() + results := MergeResults("", currentMode) + if len(results) > 0 { + limit := min(len(results), 100) + var historyList []spec.Suggestion + + if isNavUp { + for j := limit - 1; j >= 0; j-- { + historyList = append(historyList, results[j]) + } + } else { + for j := range limit { + historyList = append(historyList, results[j]) + } + } + + selected := overlay.SetHistoryList(historyList, isNavUp) + if selected != "" { + bufferMu.Lock() + naiveBuffer = selected + cursorOffset = 0 + bufferMu.Unlock() + + userNavigated.Store(true) + writeStdout([]byte(overlay.Render())) + _, _ = ptmx.Write(append([]byte{0x15}, selected...)) + } + } + i += navConsumed - 1 + continue + } + } + + if matched, consumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.SelectSuggestion); matched && config.Get().Keybindings.SelectSuggestion != "" { + intercepted = true + if overlay.IsVisible() { + 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())) + } + } + i += consumed - 1 + continue + } + + if b == 0x0d || b == 0x0a { // enter + intercepted = true + logger.Debugf("Intercepted Enter key") + + var selectedCmd string + var shouldAutoExecute bool + if config.Get().Core.AutoExecute && overlay.IsVisible() { + selectedCmd = overlay.GetCurrentCmd() + if selectedCmd != "" { + shouldAutoExecute = true + } + } + + writeStdout([]byte(overlay.ClearAndDisable())) + SetCurrentAISuggestion(nil) + renderMu.Lock() + if renderTimer != nil { + renderTimer.Stop() + renderTimer = nil + } + renderMu.Unlock() + + var cmdToSubmit string + if shouldAutoExecute { + activeModeMu.RLock() + currentMode := activeMode + activeModeMu.RUnlock() + if currentMode == "spec" { + s := strings.TrimSpace(selectedCmd) + if strings.HasSuffix(s, "/") || strings.HasSuffix(s, "\\") { + selectedCmd = s + } else { + selectedCmd = s + " " + } + } + // update the line first + _, _ = ptmx.Write(append([]byte{0x15}, selectedCmd...)) + cmdToSubmit = selectedCmd + } else { + 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) + } + 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 + } + + integration.RecordSessionCommand(cmdToSubmit) + bufferMu.Lock() + lastSubmittedCommand = strings.TrimSpace(cmdToSubmit) + naiveBuffer = "" + cursorOffset = 0 + bufferMu.Unlock() + isCommandActive.Store(true) + _, _ = ptmx.Write([]byte{b}) // forward enter to terminal + disableGhostText.Store(false) + shouldOverlayDraw = false + userNavigated.Store(false) + continue + } if b == '\033' { // check for bracketed paste start/end @@ -689,90 +909,6 @@ func runWrapper() { } } // handle escape sequences like arrow keys and functional shortcuts - if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') { - if overlay.IsVisible() && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { - intercepted = true - userNavigated.Store(true) - - arrowDir := "down" - if inputSlice[i+2] == 'A' { - arrowDir = "up" - } - moved, selectedCmd := overlay.MoveCursor(arrowDir) - if !moved { - i += 2 - continue - } - - bufferMu.Lock() - activeModeMu.RLock() - isHistMode := activeMode == "history" - activeModeMu.RUnlock() - var toWrite []byte - if isHistMode && selectedCmd != "" { - naiveBuffer = selectedCmd - cursorOffset = 0 - toWrite = append([]byte{0x15}, selectedCmd...) - } - bufCopy := naiveBuffer - offsetCopy := cursorOffset - bufferMu.Unlock() - - if len(toWrite) > 0 { - _, _ = ptmx.Write(toWrite) - } - - var b strings.Builder - if !disableGhostText.Load() { - b.WriteString(overlay.RenderGhostText(bufCopy, true, offsetCopy == 0)) - } - b.WriteString(overlay.Render()) - writeStdout([]byte(b.String())) - - i += 2 - continue - } else if !overlay.IsVisible() && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt - intercepted = true - activeModeMu.Lock() - activeMode = "history" - saveMode(activeMode) - activeModeMu.Unlock() - - activeModeMu.RLock() - currentMode := activeMode - activeModeMu.RUnlock() - results := MergeResults("", currentMode) - if len(results) > 0 { - limit := min(len(results), 100) - var historyList []spec.Suggestion - - if inputSlice[i+2] == 'A' { - for j := limit - 1; j >= 0; j-- { - historyList = append(historyList, results[j]) - } - } else { - for j := range limit { - historyList = append(historyList, results[j]) - } - } - - selected := overlay.SetHistoryList(historyList, inputSlice[i+2] == 'A') - if selected != "" { - bufferMu.Lock() - naiveBuffer = selected - cursorOffset = 0 - bufferMu.Unlock() - - userNavigated.Store(true) - writeStdout([]byte(overlay.Render())) - _, _ = ptmx.Write(append([]byte{0x15}, selected...)) - } - } - i += 2 - continue - } - } - // left/right arrow cursor tracking isLeftRightArrow := false if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') { @@ -845,7 +981,10 @@ func runWrapper() { if !intercepted { writeStdout([]byte(overlay.ClearAndDisable())) disableGhostText.Store(true) - if !isLeftRightArrow { + + // If it's a standalone ESC (n==1), don't clear the buffer because the user just wanted to hide the menu or enter vi-mode + isStandaloneEsc := n == 1 && b == '\033' + if !isLeftRightArrow && !isStandaloneEsc { bufferMu.Lock() naiveBuffer = "" cursorOffset = 0 @@ -865,101 +1004,7 @@ func runWrapper() { } 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() - logger.Debugf("Intercepted Ctrl+R, toggled mode to %q", activeMode) - if userNavigated.Load() { - bufferMu.Lock() - naiveBuffer = overlay.GetTypedQuery() - cursorOffset = 0 - bufferMu.Unlock() - _, _ = ptmx.Write(append([]byte{0x15}, overlay.GetTypedQuery()...)) - } - userNavigated.Store(false) - overlay.Show() - shouldOverlayDraw = true - // enter: enter behavior is a bit different from tab suggestions in code editor - // I want it to execute the command anyway and ignore the suggestions - // it means only tab to select suggestions, and enter to execute - // enter is not used to select suggestions - } else if b == 0x0d || b == 0x0a { - intercepted = true - logger.Debugf("Intercepted Enter key, navigated=%v", overlay.GetUserNavigated()) - var cmdToSubmit string - if overlay.IsVisible() && overlay.GetUserNavigated() { - selected := overlay.GetCurrentCmd() - if selected != "" { - cmdToSubmit = 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 + " " - } - } - _, _ = ptmx.Write(append([]byte{0x15}, selected...)) - } - } - writeStdout([]byte(overlay.ClearAndDisable())) - SetCurrentAISuggestion(nil) - renderMu.Lock() - if renderTimer != nil { - renderTimer.Stop() - renderTimer = nil - } - 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 - } - - integration.RecordSessionCommand(cmdToSubmit) - bufferMu.Lock() - lastSubmittedCommand = strings.TrimSpace(cmdToSubmit) - naiveBuffer = "" - cursorOffset = 0 - bufferMu.Unlock() - isCommandActive.Store(true) - _, _ = ptmx.Write([]byte{b}) - disableGhostText.Store(false) - shouldOverlayDraw = false - userNavigated.Store(false) - continue - } else if b == 0x03 || b == 0x15 { // ctrl+c, ctrl+u + if b == 0x03 || b == 0x15 { // ctrl+c, ctrl+u intercepted = true writeStdout([]byte(overlay.ClearAndDisable())) SetCurrentAISuggestion(nil) @@ -979,38 +1024,6 @@ func runWrapper() { shouldOverlayDraw = false userNavigated.Store(false) continue - } else if b == 0x09 { // tab: select suggestions - intercepted = true - logger.Debugf("Intercepted Tab key, visible=%v", overlay.IsVisible()) - if !overlay.IsVisible() { - shouldOverlayDraw = true - } else { - selected := overlay.GetCurrentCmd() - writeStdout([]byte(overlay.ClearAndDisable())) - - 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.ResetCursor() - shouldOverlayDraw = true - userNavigated.Store(false) - } - continue } if !intercepted {