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
This commit is contained in:
VERSE
2026-07-31 10:49:10 +07:00
committed by GitHub
parent 9f43fcb352
commit a885c7462a
7 changed files with 303 additions and 226 deletions
+6 -1
View File
@@ -160,6 +160,7 @@ shell-login = false # run the selected shell as a login shell; can also be enabl
mode = "last" # "last", "spec", or "history"
debug = false
expand-alias = true
auto-execute = false
[ui]
style = "modern" # "modern" or "classic"
@@ -167,11 +168,15 @@ 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
+12 -1
View File
@@ -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
}
+22
View File
@@ -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"`
}
@@ -64,6 +66,9 @@ type UpdaterConfig struct {
type KeybindingsConfig struct {
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)
}
+5
View File
@@ -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{
@@ -44,6 +46,9 @@ func DefaultConfig() *Config {
Keybindings: KeybindingsConfig{
ToggleMode: "ctrl+r",
ToggleMenu: "shift+tab",
SelectSuggestion: "tab",
NavigateUp: "up",
NavigateDown: "down",
},
}
}
+15 -1
View File
@@ -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
}
+11 -4
View File
@@ -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)
+161 -148
View File
@@ -674,33 +674,49 @@ func runWrapper() {
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' {
if (inputSlice[i+4] == '0' || inputSlice[i+4] == '1') && inputSlice[i+5] == '~' {
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
inBracketedPaste = inputSlice[i+4] == '0'
logger.Debugf("Intercepted bracketed paste event inPaste=%v", inBracketedPaste)
_, _ = ptmx.Write(inputSlice[i : i+6])
i += 5
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)
}
// 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') {
if isNavUp || isNavDown {
if overlay.IsVisible() {
intercepted = true
userNavigated.Store(true)
arrowDir := "down"
if inputSlice[i+2] == 'A' {
if isNavUp {
arrowDir = "up"
}
moved, selectedCmd := overlay.MoveCursor(arrowDir)
if !moved {
i += 2
i += navConsumed - 1
continue
}
@@ -729,9 +745,10 @@ func runWrapper() {
b.WriteString(overlay.Render())
writeStdout([]byte(b.String()))
i += 2
i += navConsumed - 1
continue
} else if !overlay.IsVisible() && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt
} else if naiveBuffer == "" {
// up/down arrow on empty prompt
intercepted = true
activeModeMu.Lock()
activeMode = "history"
@@ -746,7 +763,7 @@ func runWrapper() {
limit := min(len(results), 100)
var historyList []spec.Suggestion
if inputSlice[i+2] == 'A' {
if isNavUp {
for j := limit - 1; j >= 0; j-- {
historyList = append(historyList, results[j])
}
@@ -756,7 +773,7 @@ func runWrapper() {
}
}
selected := overlay.SetHistoryList(historyList, inputSlice[i+2] == 'A')
selected := overlay.SetHistoryList(historyList, isNavUp)
if selected != "" {
bufferMu.Lock()
naiveBuffer = selected
@@ -768,11 +785,130 @@ func runWrapper() {
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
}
}
i += 2
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
if i+5 < n && inputSlice[i+1] == '[' && inputSlice[i+2] == '2' && inputSlice[i+3] == '0' {
if (inputSlice[i+4] == '0' || inputSlice[i+4] == '1') && inputSlice[i+5] == '~' {
intercepted = true
inBracketedPaste = inputSlice[i+4] == '0'
logger.Debugf("Intercepted bracketed paste event inPaste=%v", inBracketedPaste)
_, _ = ptmx.Write(inputSlice[i : i+6])
i += 5
continue
}
}
// handle escape sequences like arrow keys and functional shortcuts
// 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 {