feat(overlay): ghost text

This commit is contained in:
verse91
2026-04-19 12:27:50 +07:00
parent b045aaf6af
commit edab802026
2 changed files with 112 additions and 21 deletions
+52 -1
View File
@@ -19,6 +19,7 @@ type Overlay struct {
Visible bool Visible bool
Items []core.Suggestion Items []core.Suggestion
Cursor int Cursor int
LastGhostLen int
} }
var ( var (
@@ -65,6 +66,48 @@ func fixedWidth(s string, width int) string {
return s 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 { func (o *Overlay) Render() string {
o.mu.Lock() o.mu.Lock()
defer o.mu.Unlock() defer o.mu.Unlock()
@@ -75,7 +118,7 @@ func (o *Overlay) Render() string {
var s strings.Builder var s strings.Builder
s.WriteString("\033[?7l") s.WriteString("\033[?7l")
s.WriteString("\0337") // DEC save cursor s.WriteString("\0337") // DEC save cursor for menu drawing
windowSize := maxItems windowSize := maxItems
if len(o.Items) < windowSize { if len(o.Items) < windowSize {
@@ -189,6 +232,14 @@ func (o *Overlay) ClearAndDisable() string {
var s strings.Builder var s strings.Builder
s.WriteString("\033[?7l") 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") s.WriteString("\0337")
for i := 0; i < maxItems+2; i++ { for i := 0; i < maxItems+2; i++ {
+50 -10
View File
@@ -11,7 +11,9 @@ import (
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"syscall" "syscall"
"time"
"github.com/creack/pty" "github.com/creack/pty"
"github.com/versenilvis/iris/commands/core" "github.com/versenilvis/iris/commands/core"
@@ -185,7 +187,10 @@ func runWrapper() {
} }
os.Stdout.Write([]byte(overlay.Clear())) os.Stdout.Write([]byte(overlay.Clear()))
overlay.UpdateItems(results) 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 suggestionsEnabled := true
mode := loadMode() mode := loadMode()
var renderTimer *time.Timer
var renderMu sync.Mutex
// renderOverlay decides whether to draw the suggestion menu based on current state // renderOverlay decides whether to draw the suggestion menu based on current state
renderOverlay := func() { renderOverlay := func() {
renderMu.Lock()
defer renderMu.Unlock()
if renderTimer != nil {
renderTimer.Stop()
}
if !suggestionsEnabled { if !suggestionsEnabled {
return return
} }
if naiveBuffer == "" { bufCopy := naiveBuffer
modeCopy := mode
if bufCopy == "" {
os.Stdout.Write([]byte(overlay.ClearAndDisable())) os.Stdout.Write([]byte(overlay.ClearAndDisable()))
return return
} }
debugLog("[Render] query: '%s', mode: %s", naiveBuffer, mode) // Debounce for 15ms to allow PTY to process the keystroke and update the terminal cursor.
results := mergeResults(naiveBuffer, mode) // 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)) debugLog("[Render] results found: %d", len(results))
var b strings.Builder
if len(results) == 0 { if len(results) == 0 {
os.Stdout.Write([]byte(overlay.ClearAndDisable())) b.WriteString(overlay.ClearAndDisable())
} else { } else {
var buf strings.Builder
if overlay.Visible { if overlay.Visible {
buf.WriteString(overlay.Clear()) b.WriteString(overlay.Clear())
} }
overlay.UpdateItems(results) overlay.UpdateItems(results)
buf.WriteString(overlay.Render()) b.WriteString(overlay.RenderGhostText(bufCopy))
os.Stdout.Write([]byte(buf.String())) b.WriteString(overlay.Render())
} }
os.Stdout.Write([]byte(b.String()))
})
} }
renderOverlay() renderOverlay()
@@ -269,9 +293,25 @@ func runWrapper() {
overlay.Cursor = len(overlay.Items) - 1 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 i += 2
continue 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
}
}
} }
} }