From dfaa051f31671eaf45b0bd0bc5a2694df00e3db0 Mon Sep 17 00:00:00 2001 From: Ashvin Singh <136104771+ashvin-to@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:18:53 +0530 Subject: [PATCH] fix: make Right Arrow configurable and correct overlay positioning (#110) ## Summary Closes #108 This PR fixes two issues related to terminal interaction and rendering: - Make Right Arrow ghost acceptance configurable instead of hard-coded. - Correct overlay positioning when the shell prompt wraps across terminal lines. ## Changes ### Right Arrow configuration - Add a new `navigate-right` keybinding to the configuration. - Add `navigate-right = "right"` to the default configuration to preserve existing behavior. - Update the wrapper to use the configured binding instead of a hard-coded Right Arrow check. - Forward the Right Arrow escape sequence to the PTY when `navigate-right` is unset, allowing the shell or terminal to handle it normally. ### Overlay positioning - Compute the cursor's current column from the total visual width instead of using the accumulated width directly. - Correctly handle wrapped prompts by calculating the cursor column modulo the terminal width. - Clamp the overlay position so it never extends beyond the terminal width. - Extend debug logging with the computed cursor position to aid troubleshooting. ## Why Right Arrow ghost acceptance was previously implemented as a hard-coded behavior, making it impossible to disable or remap through configuration. This change makes it consistent with the existing configurable keybindings while preserving the default behavior. Overlay positioning assumed that the prompt length represented the cursor's current column. When the prompt wrapped onto a new terminal line, this assumption caused the suggestion overlay to be positioned incorrectly. Computing the cursor column from the wrapped position ensures the overlay is aligned with the actual cursor. ## Testing - [x] Default configuration retains Right Arrow ghost acceptance. - [x] Setting `navigate-right = ""` forwards Right Arrow to the shell. - [x] Existing keybindings continue to function as before. - [x] Overlay is correctly positioned for both short and wrapped prompts. - [x] Overlay remains within terminal bounds. --- integration/overlay.go | 14 +++++++++++--- internal/config/config.go | 4 ++++ root/config_cmd.go | 1 + root/init.go | 1 + root/wrapper.go | 34 ++++++++++++++++++++++++++++------ 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/integration/overlay.go b/integration/overlay.go index 247e5d1..ff9a5e0 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -578,8 +578,6 @@ func (o *Overlay) draw() string { s.WriteString(ansi.ResetModeAutoWrap) typedLen := lipgloss.Width(o.TypedQuery) - targetCol := o.PromptLen + typedLen - width := termWidth() boxWidth := config.Get().UI.MaxWidth @@ -593,13 +591,23 @@ func (o *Overlay) draw() string { boxWidth = 40 // Minimum safe width } + // ComputeCursorCol returns the total visual width, not the column on the + // current line. When the prompt is wider than the terminal the cursor has + // wrapped, so using PromptLen directly overflows the screen and the box + // lands at the wrong horizontal position. + totalCol := o.PromptLen + typedLen + cursorCol := totalCol + if width > 0 { + cursorCol = totalCol % width + } + targetCol := cursorCol if targetCol+boxWidth > width { targetCol = width - boxWidth } if targetCol < 0 { targetCol = 0 } - logger.Debugf("Overlay draw: pLen=%d, typedLen=%d, targetCol=%d, width=%d", o.PromptLen, typedLen, targetCol, width) + logger.Debugf("Overlay draw: pLen=%d, typedLen=%d, totalCol=%d, cursorCol=%d, targetCol=%d, width=%d", o.PromptLen, typedLen, totalCol, cursorCol, targetCol, width) s.WriteString(ansi.SaveCursor) diff --git a/internal/config/config.go b/internal/config/config.go index e331f44..0bffdbd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,7 @@ type KeybindingsConfig struct { SelectSuggestion string `toml:"select"` NavigateUp string `toml:"navigate-up"` NavigateDown string `toml:"navigate-down"` + NavigateRight string `toml:"navigate-right"` } type SuggestOnEmptyConfig struct { @@ -207,6 +208,9 @@ func Load() (*Config, error) { if cfg.Keybindings.NavigateDown == "" { cfg.Keybindings.NavigateDown = "down" } + if cfg.Keybindings.NavigateRight == "" { + cfg.Keybindings.NavigateRight = "right" + } if err := validate(cfg); err != nil { return cfg, fmt.Errorf("config: invalid value: %w", err) diff --git a/root/config_cmd.go b/root/config_cmd.go index 302f324..535c973 100644 --- a/root/config_cmd.go +++ b/root/config_cmd.go @@ -104,6 +104,7 @@ toggle-menu = "shift+tab" select = "tab" navigate-up = "up" navigate-down = "down" +navigate-right = "right" ` err = os.WriteFile(path, []byte(defaultContent), 0644) if err != nil { diff --git a/root/init.go b/root/init.go index 3ba0800..2dfb847 100644 --- a/root/init.go +++ b/root/init.go @@ -283,6 +283,7 @@ toggle-menu = "shift+tab" select = "tab" navigate-up = "up" navigate-down = "down" +navigate-right = "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 c20c4e9..48b0357 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -961,12 +961,36 @@ func runWrapper() { bufferMu.Unlock() isLeftRightArrow = true } else if inputSlice[i+2] == 'C' { + _, navConsumed := config.MatchKey(inputSlice[i:], config.Get().Keybindings.NavigateRight) + if navConsumed == 0 { + if !intercepted { + writeStdout([]byte(overlay.ClearAndDisable())) + disableGhostText.Store(true) + isStandaloneEsc := n == 1 && b == '\033' + if !isStandaloneEsc { + bufferMu.Lock() + naiveBuffer = "" + cursorOffset = 0 + bufferMu.Unlock() + } + _, _ = ptmx.Write([]byte{b}) + for j := i + 1; j < n; j++ { + char := inputSlice[j] + _, _ = ptmx.Write([]byte{char}) + i = j + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '~' { + break + } + } + } + continue + } + i += navConsumed - 1 + intercepted = true bufferMu.Lock() isEmptyQuery := naiveBuffer == "" && (!overlay.IsVisible() || overlay.GetTypedQuery() == "") bufferMu.Unlock() if isEmptyQuery { - intercepted = true - i += 2 continue } @@ -978,16 +1002,14 @@ func runWrapper() { } bufferMu.Unlock() - if len(ghostText) > 0 { - intercepted = true - bufferMu.Lock() + if len(ghostText) > 0 { + bufferMu.Lock() naiveBuffer += ghostText cursorOffset = 0 bufferMu.Unlock() overlay.ClearGhostTextState() _, _ = ptmx.Write([]byte(ghostText)) shouldOverlayDraw = true - i += 2 continue }