diff --git a/integration/overlay.go b/integration/overlay.go index 7a407dc..3774b4a 100644 --- a/integration/overlay.go +++ b/integration/overlay.go @@ -15,10 +15,11 @@ const ( ) type Overlay struct { - mu sync.Mutex - Visible bool - Items []core.Suggestion - Cursor int + mu sync.Mutex + Visible bool + Items []core.Suggestion + Cursor int + LastGhostLen int } var ( @@ -65,6 +66,48 @@ func fixedWidth(s string, width int) string { return s } +func (o *Overlay) RenderGhostText(buffer string) string { + o.mu.Lock() + defer o.mu.Unlock() + + if !o.Visible || len(o.Items) == 0 { + return "" + } + + var s strings.Builder + + ghostText := "" + if buffer != "" { + topCmd := o.Items[0].Cmd + if strings.HasPrefix(strings.ToLower(topCmd), strings.ToLower(buffer)) { + ghostText = topCmd[len(buffer):] + } + } + + padLen := o.LastGhostLen - len(ghostText) + if padLen < 0 { + padLen = 0 + } + + // add extra padding to erase any stray characters left by fast backspaces + // before the debounce timer fired. 10 spaces is safe and won't hit right prompts + padLen += 10 + + if ghostText != "" || padLen > 0 { + s.WriteString("\0337") // SAVE CURSOR at prompt + if ghostText != "" { + s.WriteString("\033[90m" + ghostText + "\033[0m") + } + if padLen > 0 { + s.WriteString(strings.Repeat(" ", padLen)) + } + s.WriteString("\0338") // RESTORE CURSOR back to prompt + o.LastGhostLen = len(ghostText) + } + + return s.String() +} + func (o *Overlay) Render() string { o.mu.Lock() defer o.mu.Unlock() @@ -75,7 +118,7 @@ func (o *Overlay) Render() string { var s strings.Builder s.WriteString("\033[?7l") - s.WriteString("\0337") // DEC save cursor + s.WriteString("\0337") // DEC save cursor for menu drawing windowSize := maxItems if len(o.Items) < windowSize { @@ -189,6 +232,14 @@ func (o *Overlay) ClearAndDisable() string { var s strings.Builder s.WriteString("\033[?7l") + + if o.LastGhostLen > 0 { + s.WriteString("\0337") // save + s.WriteString(strings.Repeat(" ", o.LastGhostLen+10)) + s.WriteString("\0338") // restore + o.LastGhostLen = 0 + } + s.WriteString("\0337") for i := 0; i < maxItems+2; i++ { diff --git a/root/wrapper.go b/root/wrapper.go index 84d290a..5014f13 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -11,7 +11,9 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" + "time" "github.com/creack/pty" "github.com/versenilvis/iris/commands/core" @@ -185,7 +187,10 @@ func runWrapper() { } os.Stdout.Write([]byte(overlay.Clear())) overlay.UpdateItems(results) - os.Stdout.Write([]byte(overlay.Render())) + var rBuf strings.Builder + rBuf.WriteString(overlay.RenderGhostText(query)) + rBuf.WriteString(overlay.Render()) + os.Stdout.Write([]byte(rBuf.String())) } }() @@ -193,32 +198,51 @@ func runWrapper() { suggestionsEnabled := true mode := loadMode() + var renderTimer *time.Timer + var renderMu sync.Mutex + // renderOverlay decides whether to draw the suggestion menu based on current state renderOverlay := func() { + renderMu.Lock() + defer renderMu.Unlock() + + if renderTimer != nil { + renderTimer.Stop() + } + if !suggestionsEnabled { return } - if naiveBuffer == "" { + bufCopy := naiveBuffer + modeCopy := mode + + if bufCopy == "" { os.Stdout.Write([]byte(overlay.ClearAndDisable())) return } - debugLog("[Render] query: '%s', mode: %s", naiveBuffer, mode) - results := mergeResults(naiveBuffer, mode) - debugLog("[Render] results found: %d", len(results)) + // Debounce for 15ms to allow PTY to process the keystroke and update the terminal cursor. + // This completely prevents the asynchronous ghost text race condition where the PTY echo + // overwrites the first letter of our ghost text! + renderTimer = time.AfterFunc(15*time.Millisecond, func() { + debugLog("[Render] query: '%s', mode: %s", bufCopy, modeCopy) + results := mergeResults(bufCopy, modeCopy) + debugLog("[Render] results found: %d", len(results)) - if len(results) == 0 { - os.Stdout.Write([]byte(overlay.ClearAndDisable())) - } else { - var buf strings.Builder - if overlay.Visible { - buf.WriteString(overlay.Clear()) + var b strings.Builder + if len(results) == 0 { + b.WriteString(overlay.ClearAndDisable()) + } else { + if overlay.Visible { + b.WriteString(overlay.Clear()) + } + overlay.UpdateItems(results) + b.WriteString(overlay.RenderGhostText(bufCopy)) + b.WriteString(overlay.Render()) } - overlay.UpdateItems(results) - buf.WriteString(overlay.Render()) - os.Stdout.Write([]byte(buf.String())) - } + os.Stdout.Write([]byte(b.String())) + }) } renderOverlay() @@ -269,9 +293,25 @@ func runWrapper() { overlay.Cursor = len(overlay.Items) - 1 } } - os.Stdout.Write([]byte(overlay.Render())) + var rBuf strings.Builder + rBuf.WriteString(overlay.RenderGhostText(naiveBuffer)) + rBuf.WriteString(overlay.Render()) + os.Stdout.Write([]byte(rBuf.String())) i += 2 continue + } else if overlay.Visible && inputSlice[i+2] == 'C' { // right arrow + topCmd := overlay.Items[0].Cmd + if strings.HasPrefix(strings.ToLower(topCmd), strings.ToLower(naiveBuffer)) { + ghostText := topCmd[len(naiveBuffer):] + if len(ghostText) > 0 { + intercepted = true + naiveBuffer += ghostText + ptmx.Write([]byte(ghostText)) + shouldOverlayDraw = true + i += 2 + continue + } + } } }