fix: better suggestion and overlay movement on key presses (#12)

- support history suggestions for aliases
https://github.com/versenilvis/IRIS/commit/0ae25d05ce0faf8ff2eeee9aa3fbb5889d9ad511
- merge spec and history results
(https://github.com/versenilvis/IRIS/commit/315c94f12bb689ad6b39b1f0ce5e0bdef71f38d5)
- add 25ms pty throttle and optimize ctrl+l
(https://github.com/versenilvis/IRIS/commit/1e603d74c208be00f6b2cb79a628416455711c3a)
- align suggestion menu to col 1
(https://github.com/versenilvis/IRIS/commit/e307b210849af65276e93e1cff8895f3b54937ff)
- resolve zsh ipc fd mismatch and generate autocomplete hooks
(https://github.com/versenilvis/IRIS/pull/12/commits/2199607fad8122ec441ff4b99e562ca62bd948ff)
- fix: eliminate prompt duplication and freeze UI movement during menu
navigation
https://github.com/versenilvis/IRIS/pull/12/commits/6783989a49f0a68d01bad542588983a5c7b597a2
This commit is contained in:
VERSE
2026-07-01 16:11:38 +07:00
committed by GitHub
parent 69eee40248
commit aca3d1670c
8 changed files with 619 additions and 231 deletions
+16 -2
View File
@@ -19,6 +19,16 @@ func GetAlias(name string) (string, bool) {
return val, ok return val, ok
} }
func GetAliasesCopy() map[string]string {
shellAliasesMu.RLock()
defer shellAliasesMu.RUnlock()
cp := make(map[string]string, len(ShellAliases))
for k, v := range ShellAliases {
cp[k] = v
}
return cp
}
// Lookup finds matching suggestions for your input by looking at how many words you typed // Lookup finds matching suggestions for your input by looking at how many words you typed
// it changes aliases to real commands, finds subcommands inside others, and runs generators for more suggestions // it changes aliases to real commands, finds subcommands inside others, and runs generators for more suggestions
// e.g. Lookup("git che") -> suggests "git checkout" // e.g. Lookup("git che") -> suggests "git checkout"
@@ -131,6 +141,10 @@ func Lookup(input string) []Suggestion {
depth++ depth++
continue continue
} }
// invalid subcommand word typed
if len(currentSubs) > 0 {
return nil
}
break break
} }
@@ -237,7 +251,7 @@ func Lookup(input string) []Suggestion {
} }
} }
if partial == "" || (len(partial) > 0 && partial[0] == '-') { if len(partial) > 0 && partial[0] == '-' {
usedOpts := make(map[string]bool) usedOpts := make(map[string]bool)
for _, t := range tokens { for _, t := range tokens {
if strings.HasPrefix(t, "-") { if strings.HasPrefix(t, "-") {
@@ -247,7 +261,7 @@ func Lookup(input string) []Suggestion {
for _, opt := range currentOpts { for _, opt := range currentOpts {
if !usedOpts[opt.Name] && (partial == "" || HasPrefix(opt.Name, partial)) { if !usedOpts[opt.Name] && (partial == "" || HasPrefix(opt.Name, partial)) {
results = append(results, Suggestion{ results = append(results, Suggestion{
Cmd: prefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName, Cmd: linePrefix + " " + opt.Name, Desc: opt.Description, Icon: rootCmdName,
}) })
} }
} }
+3
View File
@@ -218,6 +218,9 @@ func GitPushPullGenerator(tokens []string, prefix string, partial string) []core
} }
// remote is set, suggest local branches only (no duplicates with origin/xxx) // remote is set, suggest local branches only (no duplicates with origin/xxx)
if len(pArgs) >= 2 {
return nil
}
return gitLocalBranchGenerator(tokens, prefix, partial) return gitLocalBranchGenerator(tokens, prefix, partial)
} }
+79 -28
View File
@@ -30,7 +30,7 @@ func init() {
idMapCache = make(map[string]int) idMapCache = make(map[string]int)
} }
func SearchHistory(query string) ([]HistResult, error) { func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
@@ -143,68 +143,119 @@ func SearchHistory(query string) ([]HistResult, error) {
return results, nil return results, nil
} }
matches := searcherCache.SearchWithScores(query, &fuzzy.SearchOptions{Limit: 200}) var alternativeQueries []string
for name, target := range aliases {
if target != "" {
qLow := strings.ToLower(query)
tLow := strings.ToLower(target)
nLow := strings.ToLower(name)
// when query has a clear first word, only keep history entries that share the same first word if qLow == tLow {
// this prevents e.g. curl commands from showing up when typing "git ..." alternativeQueries = append(alternativeQueries, name)
queryFirstWord := "" } else if strings.HasPrefix(qLow, tLow+" ") {
if strings.IndexByte(query, ' ') != -1 { suffix := query[len(target):]
if fields := strings.Fields(query); len(fields) > 0 { alternativeQueries = append(alternativeQueries, name+suffix)
queryFirstWord = strings.ToLower(fields[0]) }
if qLow == nLow {
alternativeQueries = append(alternativeQueries, target)
} else if strings.HasPrefix(qLow, nLow+" ") {
suffix := query[len(name):]
alternativeQueries = append(alternativeQueries, target+suffix)
}
} }
} }
var results []HistResult var results []HistResult
seenCmds := make(map[string]bool)
addMatches := func(q string) {
qLow := strings.ToLower(q)
queryFirstWord := ""
if strings.IndexByte(qLow, ' ') != -1 {
if fields := strings.Fields(qLow); len(fields) > 0 {
queryFirstWord = fields[0]
}
}
matches := searcherCache.SearchWithScores(q, &fuzzy.SearchOptions{Limit: 200})
for _, m := range matches { for _, m := range matches {
if queryFirstWord != "" { if seenCmds[m.Str] {
continue
}
// filter results by command name match
firstWord := m.Str firstWord := m.Str
if idx := strings.IndexByte(m.Str, ' '); idx != -1 { if idx := strings.IndexByte(m.Str, ' '); idx != -1 {
firstWord = m.Str[:idx] firstWord = m.Str[:idx]
} }
if !strings.EqualFold(firstWord, queryFirstWord) { firstWordLow := strings.ToLower(firstWord)
if queryFirstWord != "" {
if firstWordLow != queryFirstWord {
continue
}
} else {
if !strings.HasPrefix(firstWordLow, qLow) {
continue continue
} }
} }
seenCmds[m.Str] = true
results = append(results, HistResult{ results = append(results, HistResult{
ID: idMapCache[m.Str], ID: idMapCache[m.Str],
Cmd: m.Str, Cmd: m.Str,
FuzzyScore: m.Score, FuzzyScore: m.Score,
}) })
} }
}
addMatches(query)
for _, altQ := range alternativeQueries {
addMatches(altQ)
}
// within the same tier, we sort by ID
// tier 1: exact match
// tier 2: prefix match
// tier 3: substring match
// tier 4: fuzzy match
getTier := func(cmd, q string) int { getTier := func(cmd, q string) int {
bestTier := 4
check := func(ql string) {
cmdLow := strings.ToLower(cmd) cmdLow := strings.ToLower(cmd)
qLow := strings.ToLower(q) qlLow := strings.ToLower(ql)
if cmdLow == qLow { tier := 4
return 1 if cmdLow == qlLow {
tier = 1
} else if strings.HasPrefix(cmdLow, qlLow) {
tier = 2
} else if strings.Contains(cmdLow, qlLow) {
tier = 3
} }
if strings.HasPrefix(cmdLow, qLow) { if tier < bestTier {
return 2 bestTier = tier
} }
if strings.Contains(cmdLow, qLow) {
return 3
} }
return 4 check(q)
for _, altQ := range alternativeQueries {
check(altQ)
}
return bestTier
}
tiers := make([]int, len(results))
for i, r := range results {
tiers[i] = getTier(r.Cmd, query)
} }
sort.SliceStable(results, func(i, j int) bool { sort.SliceStable(results, func(i, j int) bool {
tI := getTier(results[i].Cmd, query) tI := tiers[i]
tJ := getTier(results[j].Cmd, query) tJ := tiers[j]
if tI != tJ { if tI != tJ {
return tI < tJ // lower tier is better return tI < tJ
} }
// If both are fuzzy matches (Tier 4), prioritize fuzzy score first!
if tI == 4 && results[i].FuzzyScore != results[j].FuzzyScore { if tI == 4 && results[i].FuzzyScore != results[j].FuzzyScore {
return results[i].FuzzyScore > results[j].FuzzyScore return results[i].FuzzyScore > results[j].FuzzyScore
} }
// if same tier (and same fuzzy score), sort by ID descending (most recent first)
return results[i].ID > results[j].ID return results[i].ID > results[j].ID
}) })
+51 -20
View File
@@ -2,16 +2,18 @@ package integration
import ( import (
"fmt" "fmt"
"os"
"strings" "strings"
"sync" "sync"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/versenilvis/iris/commands/core" "github.com/versenilvis/iris/commands/core"
"golang.org/x/term"
) )
const ( const (
boxWidth = 72 boxWidth = 72
maxItems = 6 // max items showing in the menu preview maxItems = 6
) )
type Overlay struct { type Overlay struct {
@@ -21,6 +23,8 @@ type Overlay struct {
Cursor int Cursor int
StartIdx int StartIdx int
LastGhostLen int LastGhostLen int
TypedQuery string
UserNavigated bool
} }
var ( var (
@@ -53,11 +57,10 @@ func (o *Overlay) UpdateItems(items []core.Suggestion) {
o.Items = items o.Items = items
o.Visible = len(o.Items) > 0 o.Visible = len(o.Items) > 0
o.Cursor = 0 // reset to top result on update o.Cursor = 0
o.StartIdx = 0 o.StartIdx = 0
} }
// fixedWidth pads or truncates a string to exact rune width
func fixedWidth(s string, width int) string { func fixedWidth(s string, width int) string {
runes := []rune(s) runes := []rune(s)
if len(runes) > width { if len(runes) > width {
@@ -93,11 +96,11 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string {
} }
// add extra padding to erase any stray characters left by fast backspaces // 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 // before the debounce timer fired, 10 spaces is safe and won't hit right prompts
padLen += 10 padLen += 10
if ghostText != "" || padLen > 0 { if ghostText != "" || padLen > 0 {
s.WriteString("\0337") // SAVE CURSOR at prompt s.WriteString("\0337") // save cursor at prompt
if ghostText != "" { if ghostText != "" {
s.WriteString("\033[90m") s.WriteString("\033[90m")
s.WriteString(ghostText) s.WriteString(ghostText)
@@ -106,7 +109,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string {
if padLen > 0 { if padLen > 0 {
s.WriteString(strings.Repeat(" ", padLen)) s.WriteString(strings.Repeat(" ", padLen))
} }
s.WriteString("\0338") // RESTORE CURSOR back to prompt s.WriteString("\0338") // restore cursor back to prompt
o.LastGhostLen = len(ghostText) o.LastGhostLen = len(ghostText)
} }
@@ -123,16 +126,32 @@ 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 for menu drawing
var offset int
if o.UserNavigated && len(o.Items) > 0 && o.Cursor >= 0 && o.Cursor < len(o.Items) {
currentCmd := o.Items[o.Cursor].Cmd
typedLen := len([]rune(o.TypedQuery))
currentLen := len([]rune(currentCmd))
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || width <= 0 {
width = 120
}
if currentLen+4 < width {
offset = currentLen - typedLen
} else {
curCol := (currentLen + 2) % width
typedCol := (typedLen + 2) % width
offset = curCol - typedCol
}
}
s.WriteString("\0337")
windowSize := maxItems windowSize := maxItems
if len(o.Items) < windowSize { if len(o.Items) < windowSize {
windowSize = len(o.Items) windowSize = len(o.Items)
} }
// when you use up arrow key, the selection bar will stick with the second item
// it stays still at second position until you reach the limit of the list
// but not apply the same with down arrow key
scrolloffUp := 1 scrolloffUp := 1
scrolloffDown := 0 scrolloffDown := 0
if windowSize <= 3 { if windowSize <= 3 {
@@ -159,22 +178,23 @@ func (o *Overlay) Render() string {
start := o.StartIdx start := o.StartIdx
end := start + windowSize end := start + windowSize
totalLines := windowSize + 2 // top border + items + bottom border totalLines := windowSize + 2
// if we reach the last lines of terminal, it will auto expand space to have space for the menu
for range totalLines { for range totalLines {
s.WriteByte('\n') s.WriteByte('\n')
} }
fmt.Fprintf(&s, "\033[%dA", totalLines) fmt.Fprintf(&s, "\033[%dA", totalLines)
// re-save after scroll up
// it means when you scroll up, then scroll down to the prompt, the menu is still be there
s.WriteString("\0337") s.WriteString("\0337")
// top border with scroll indicator
s.WriteString("\0338") s.WriteString("\0338")
fmt.Fprintf(&s, "\033[%dB", 1) fmt.Fprintf(&s, "\033[%dB", 1)
s.WriteString("\033[2K") s.WriteString("\033[2K")
if offset > 0 {
fmt.Fprintf(&s, "\033[%dD", offset)
} else if offset < 0 {
fmt.Fprintf(&s, "\033[%dC", -offset)
}
scrollInfo := "" scrollInfo := ""
if len(o.Items) > windowSize { if len(o.Items) > windowSize {
@@ -193,6 +213,11 @@ func (o *Overlay) Render() string {
s.WriteString("\0338") s.WriteString("\0338")
fmt.Fprintf(&s, "\033[%dB", (i-start)+2) fmt.Fprintf(&s, "\033[%dB", (i-start)+2)
s.WriteString("\033[2K") s.WriteString("\033[2K")
if offset > 0 {
fmt.Fprintf(&s, "\033[%dD", offset)
} else if offset < 0 {
fmt.Fprintf(&s, "\033[%dC", -offset)
}
it := o.Items[i] it := o.Items[i]
rawIcon := fixedWidth(it.Icon, iconW) rawIcon := fixedWidth(it.Icon, iconW)
@@ -216,14 +241,18 @@ func (o *Overlay) Render() string {
} }
} }
// Bottom border
s.WriteString("\0338") s.WriteString("\0338")
fmt.Fprintf(&s, "\033[%dB", windowSize+2) fmt.Fprintf(&s, "\033[%dB", windowSize+2)
s.WriteString("\033[2K") s.WriteString("\033[2K")
if offset > 0 {
fmt.Fprintf(&s, "\033[%dD", offset)
} else if offset < 0 {
fmt.Fprintf(&s, "\033[%dC", -offset)
}
bottomBorder := "╰" + strings.Repeat("─", boxWidth) + "╯" bottomBorder := "╰" + strings.Repeat("─", boxWidth) + "╯"
s.WriteString(borderStyle.Render(bottomBorder)) s.WriteString(borderStyle.Render(bottomBorder))
s.WriteString("\0338") // restore to prompt s.WriteString("\0338")
s.WriteString("\033[?7h") s.WriteString("\033[?7h")
return s.String() return s.String()
} }
@@ -251,20 +280,22 @@ func (o *Overlay) ClearAndDisable() string {
o.mu.Lock() o.mu.Lock()
defer o.mu.Unlock() defer o.mu.Unlock()
if !o.Visible { if !o.Visible && len(o.Items) == 0 && o.LastGhostLen == 0 {
return "" return ""
} }
o.Visible = false o.Visible = false
o.Items = nil o.Items = nil
o.TypedQuery = ""
o.UserNavigated = false
var s strings.Builder var s strings.Builder
s.WriteString("\033[?7l") s.WriteString("\033[?7l")
if o.LastGhostLen > 0 { if o.LastGhostLen > 0 {
s.WriteString("\0337") // save s.WriteString("\0337")
s.WriteString(strings.Repeat(" ", o.LastGhostLen+10)) s.WriteString(strings.Repeat(" ", o.LastGhostLen+10))
s.WriteString("\0338") // restore s.WriteString("\0338")
o.LastGhostLen = 0 o.LastGhostLen = 0
} }
+34 -3
View File
@@ -21,7 +21,7 @@ For example, add this to your ~/.zshrc:
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
shell := args[0] shell := args[0]
switch shell { switch shell {
case "bash", "zsh": case "zsh":
fmt.Printf(` fmt.Printf(`
# Iris Autostart Hook # Iris Autostart Hook
if [ -n "$TMUX" ] && [ -n "$IRIS_PID" ]; then if [ -n "$TMUX" ] && [ -n "$IRIS_PID" ]; then
@@ -31,10 +31,41 @@ if [ -n "$TMUX" ] && [ -n "$IRIS_PID" ]; then
fi fi
if [ -z "$IRIS_PID" ]; then if [ -z "$IRIS_PID" ]; then
export IRIS_ACTIVE_SHELL="%s" export IRIS_ACTIVE_SHELL="zsh"
exec iris exec iris
fi fi
`, shell)
# Iris Autocomplete Hook
if [ -n "$IRIS_PID" ] && [ -n "$IRIS_FD" ]; then
_iris_send_lbuffer() {
print -u $IRIS_FD -N -r -- "$LBUFFER" 2>/dev/null
}
_iris_precmd() {
print -u $IRIS_FD -N -r -- "IRIS_CMD_STOP" 2>/dev/null
}
autoload -Uz add-zle-hook-widget
autoload -Uz add-zsh-hook
add-zle-hook-widget line-pre-redraw _iris_send_lbuffer
add-zsh-hook precmd _iris_precmd
fi
`)
case "bash":
fmt.Printf(`
# Iris Autostart Hook
if [ -n "$TMUX" ] && [ -n "$IRIS_PID" ]; then
if ps -o comm= -p $PPID 2>/dev/null | grep -q "tmux"; then
unset IRIS_PID IRIS_IS_CHILD IRIS_FD
fi
fi
if [ -z "$IRIS_PID" ]; then
export IRIS_ACTIVE_SHELL="bash"
exec iris
fi
`)
case "fish": case "fish":
fmt.Printf(` fmt.Printf(`
# Iris Autostart Hook # Iris Autostart Hook
-2
View File
@@ -57,9 +57,7 @@ func init() {
if shellFlag != "" { if shellFlag != "" {
config.Get().Core.Shell = shellFlag config.Get().Core.Shell = shellFlag
} }
if debugMode {
config.Get().Core.Debug = true config.Get().Core.Debug = true
}
if config.Get().Core.Debug { if config.Get().Core.Debug {
logDir, err := config.CachePath() logDir, err := config.CachePath()
if err == nil { if err == nil {
+43 -35
View File
@@ -12,53 +12,61 @@ import (
// mergeResults collects and dedupes suggestions for a query and mode // mergeResults collects and dedupes suggestions for a query and mode
// example: mergeResults("git ", "spec") // example: mergeResults("git ", "spec")
func MergeResults(query string, mode string) []core.Suggestion { func MergeResults(query string, mode string) []core.Suggestion {
if query == "" && mode != "history" {
debugLog("[Merge] Query empty, returning nil")
return nil
}
maxSugg := config.Get().UI.MaxSuggestions maxSugg := config.Get().UI.MaxSuggestions
normalizedQuery := strings.TrimSpace(query)
seen := make(map[string]bool) seen := make(map[string]bool)
deduped := []core.Suggestion{} deduped := []core.Suggestion{}
if mode == "history" { // always call lookup to scan aliases and get spec suggestions
histResults, _ := integration.SearchHistory(query) var cmdResults []core.Suggestion
for _, h := range histResults { if query != "" {
normalizedCmd := strings.TrimSpace(h.Cmd)
if seen[normalizedCmd] {
continue
}
seen[normalizedCmd] = true
deduped = append(deduped, core.Suggestion{
Cmd: h.Cmd,
Desc: " history",
Icon: fmt.Sprintf("%d", h.ID),
})
if len(deduped) >= maxSugg {
break
}
}
debugLog("[Merge] History mode found %d items", len(deduped))
return deduped
}
debugLog("[Merge] Calling Lookup for '%s'", query) debugLog("[Merge] Calling Lookup for '%s'", query)
cmdResults := core.Lookup(query) cmdResults = core.Lookup(query)
debugLog("[Merge] Lookup returned %d raw items", len(cmdResults))
for _, s := range cmdResults {
normalizedCmd := strings.TrimSpace(s.Cmd)
if normalizedCmd == normalizedQuery { // filter exact matches to avoid loops
debugLog("[Merge] Filtered EXACT MATCH: '%s'", normalizedCmd)
continue
} }
// search history if in history mode
var histResults []integration.HistResult
if mode == "history" {
aliases := core.GetAliasesCopy()
histResults, _ = integration.SearchHistory(query, aliases)
}
normalizedQuery := strings.TrimSpace(query)
// add suggestion helper to deduplicate
addSuggestion := func(s core.Suggestion) {
normalizedCmd := strings.TrimSpace(s.Cmd)
if normalizedCmd == "" {
return
}
// filter exact match to avoid loops and redundant suggestions
if normalizedCmd == normalizedQuery {
return
}
if !seen[s.Cmd] { if !seen[s.Cmd] {
seen[s.Cmd] = true seen[s.Cmd] = true
deduped = append(deduped, s) deduped = append(deduped, s)
} }
} }
if mode == "history" {
// history mode: history first, then spec/alias
for _, h := range histResults {
addSuggestion(core.Suggestion{
Cmd: h.Cmd,
Desc: " history",
Icon: fmt.Sprintf("%d", h.ID),
})
}
for _, s := range cmdResults {
addSuggestion(s)
}
} else {
// spec mode: spec/alias only
for _, s := range cmdResults {
addSuggestion(s)
}
}
if len(deduped) > maxSugg { if len(deduped) > maxSugg {
deduped = deduped[:maxSugg] deduped = deduped[:maxSugg]
} }
+344 -92
View File
@@ -48,8 +48,20 @@ func saveMode(mode string) {
var ( var (
oldState *term.State oldState *term.State
oldStateMu sync.Mutex oldStateMu sync.Mutex
activeMode string
activeModeMu sync.RWMutex
stdoutMu sync.Mutex
) )
func writeStdout(data []byte) {
if len(data) == 0 {
return
}
stdoutMu.Lock()
defer stdoutMu.Unlock()
_, _ = os.Stdout.Write(data)
}
// restoreTerminal restores the terminal state if needed // restoreTerminal restores the terminal state if needed
func restoreTerminal() { func restoreTerminal() {
oldStateMu.Lock() oldStateMu.Lock()
@@ -65,6 +77,10 @@ func restoreTerminal() {
// it handles raw terminal mode to intercept keystrokes and // it handles raw terminal mode to intercept keystrokes and
// coordinates between the shell process and the suggestion overlay // coordinates between the shell process and the suggestion overlay
func runWrapper() { func runWrapper() {
var naiveBuffer string
cursorOffset := 0
var bufferMu sync.Mutex
r, w, err := os.Pipe() // pipe for ipc communication from shell to iris r, w, err := os.Pipe() // pipe for ipc communication from shell to iris
if err != nil { if err != nil {
return return
@@ -86,9 +102,9 @@ func runWrapper() {
ctx := context.Background() ctx := context.Background()
c := exec.CommandContext(ctx, adapter.GetShellPath()) c := exec.CommandContext(ctx, adapter.GetShellPath())
c.ExtraFiles = make([]*os.File, 11) c.ExtraFiles = make([]*os.File, 11)
// pass write end of pipe to shell as fd 10 (I chose 10 just because it won't conflict with other file descriptors) // pass write end of pipe to shell as fd 13 (since index 10 maps to 13)
c.ExtraFiles[10] = w c.ExtraFiles[10] = w
c.Env = adapter.GetEnv(10, os.Getpid()) c.Env = adapter.GetEnv(13, os.Getpid())
ptmx, err := pty.Start(c) ptmx, err := pty.Start(c)
if err != nil { if err != nil {
@@ -194,7 +210,7 @@ func runWrapper() {
} }
continue continue
} }
_, _ = os.Stdout.Write(buf[:n]) writeStdout(buf[:n])
} }
}() }()
@@ -258,81 +274,93 @@ func runWrapper() {
continue continue
} }
if isExecuting() { if overlay.UserNavigated {
continue continue
} }
results := MergeResults(query, "spec") // sync local buffer with actual command line
bufferMu.Lock()
naiveBuffer = query
cursorOffset = 0
bufferMu.Unlock()
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
results := MergeResults(query, currentMode)
if len(results) == 0 { if len(results) == 0 {
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) writeStdout([]byte(overlay.ClearAndDisable()))
continue continue
} }
_, _ = os.Stdout.Write([]byte(overlay.Clear())) writeStdout([]byte(overlay.Clear()))
overlay.TypedQuery = query
overlay.UserNavigated = false
overlay.UpdateItems(results) overlay.UpdateItems(results)
var rBuf strings.Builder var rBuf strings.Builder
if !disableGhostText.Load() { if !disableGhostText.Load() {
rBuf.WriteString(overlay.RenderGhostText(query, false)) rBuf.WriteString(overlay.RenderGhostText(query, false))
} }
rBuf.WriteString(overlay.Render()) rBuf.WriteString(overlay.Render())
_, _ = os.Stdout.Write([]byte(rBuf.String())) writeStdout([]byte(rBuf.String()))
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
debugLog("[IPC] scanner error: %v", err) debugLog("[IPC] scanner error: %v", err)
} }
}() }()
var naiveBuffer string
suggestionsEnabled := true suggestionsEnabled := true
mode := loadMode() activeModeMu.Lock()
activeMode = loadMode()
activeModeMu.Unlock()
writeStdout([]byte(overlay.Clear()))
var renderTimer *time.Timer var renderTimer *time.Timer
var renderMu sync.Mutex var renderMu sync.Mutex
// renderOverlay decides whether to draw the suggestion menu based on current state renderMenuNow := func() {
renderOverlay = func() {
renderMu.Lock()
defer renderMu.Unlock()
if renderTimer != nil {
renderTimer.Stop()
}
if !suggestionsEnabled || isExecuting() {
return
}
bufCopy := naiveBuffer
modeCopy := mode
navCopy := userNavigated
if bufCopy == "" && !navCopy {
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
return
}
// 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() {
if isExecuting() { if isExecuting() {
return return
} }
// copy state safely inside timer
bufferMu.Lock()
bufCopy := naiveBuffer
offsetCopy := cursorOffset
bufferMu.Unlock()
activeModeMu.RLock()
modeCopy := activeMode
activeModeMu.RUnlock()
navCopy := userNavigated
runes := []rune(bufCopy)
if offsetCopy > 0 && offsetCopy <= len(runes) {
bufCopy = string(runes[:len(runes)-offsetCopy])
}
var b strings.Builder var b strings.Builder
if !navCopy { if !navCopy {
if bufCopy == "" {
writeStdout([]byte(overlay.ClearAndDisable()))
return
}
debugLog("[Render] query: '%s', mode: %s", bufCopy, modeCopy) debugLog("[Render] query: '%s', mode: %s", bufCopy, modeCopy)
results := MergeResults(bufCopy, modeCopy) results := MergeResults(bufCopy, modeCopy)
debugLog("[Render] results found: %d", len(results)) debugLog("[Render] results found: %d", len(results))
if len(results) == 0 { if len(results) == 0 || (len(results) == 1 && strings.TrimSpace(results[0].Cmd) == strings.TrimSpace(bufCopy) && !strings.HasSuffix(bufCopy, " ")) {
b.WriteString(overlay.ClearAndDisable()) b.WriteString(overlay.ClearAndDisable())
_, _ = os.Stdout.Write([]byte(b.String())) writeStdout([]byte(b.String()))
return return
} }
if overlay.Visible { if overlay.Visible {
b.WriteString(overlay.Clear()) b.WriteString(overlay.Clear())
} }
overlay.TypedQuery = bufCopy
overlay.UpdateItems(results) overlay.UpdateItems(results)
} else { } else {
if overlay.Visible { if overlay.Visible {
@@ -340,32 +368,68 @@ func runWrapper() {
} }
} }
overlay.UserNavigated = navCopy
if !disableGhostText.Load() { if !disableGhostText.Load() {
b.WriteString(overlay.RenderGhostText(bufCopy, navCopy)) b.WriteString(overlay.RenderGhostText(bufCopy, navCopy))
} }
currentCmd := ""
if len(overlay.Items) > 0 && overlay.Cursor >= 0 && overlay.Cursor < len(overlay.Items) {
currentCmd = overlay.Items[overlay.Cursor].Cmd
}
debugLog("[RenderOverlay] nav: %v, cursor: %d, typedQuery: '%s', currentCmd: '%s'", navCopy, overlay.Cursor, overlay.TypedQuery, currentCmd)
b.WriteString(overlay.Render()) b.WriteString(overlay.Render())
_, _ = os.Stdout.Write([]byte(b.String())) writeStdout([]byte(b.String()))
})
} }
renderOverlay() var isNavTimerRunning bool
renderNow := func() { renderOverlay = func() {
renderMu.Lock() renderMu.Lock()
defer renderMu.Unlock()
if !suggestionsEnabled || isExecuting() {
if renderTimer != nil {
renderTimer.Stop()
renderTimer = nil
}
isNavTimerRunning = false
return
}
navCopy := userNavigated
if navCopy {
if isNavTimerRunning {
return
}
if renderTimer != nil { if renderTimer != nil {
renderTimer.Stop() renderTimer.Stop()
} }
isNavTimerRunning = true
// IMPORTANT: please dont change this to above 24ms or under 19ms
// I still can get the reason why it only works stably between 20-23ms
renderTimer = time.AfterFunc(23*time.Millisecond, func() {
renderMu.Lock()
renderTimer = nil
isNavTimerRunning = false
renderMu.Unlock() renderMu.Unlock()
renderMenuNow()
})
} else {
if renderTimer != nil {
renderTimer.Stop()
}
isNavTimerRunning = false
renderTimer = time.AfterFunc(25*time.Millisecond, func() {
renderMu.Lock()
renderTimer = nil
renderMu.Unlock()
renderMenuNow()
})
}
}
var b strings.Builder renderOverlay()
if overlay.Visible {
if !disableGhostText.Load() {
b.WriteString(overlay.RenderGhostText(naiveBuffer, userNavigated))
}
b.WriteString(overlay.Render())
}
_, _ = os.Stdout.Write([]byte(b.String()))
}
// reads from stdin and decides what to forward or intercept // reads from stdin and decides what to forward or intercept
// for most cases, I just handle the already have terminal shortcuts // for most cases, I just handle the already have terminal shortcuts
@@ -390,6 +454,15 @@ func runWrapper() {
intercepted := false intercepted := false
if b == '\033' { 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
_, _ = ptmx.Write(inputSlice[i : i+6])
i += 5
continue
}
}
// handle escape sequences like arrow keys and functional shortcuts // handle escape sequences like arrow keys and functional shortcuts
if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') { if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') {
// shift tab: hide/unhide menu dropdown // shift tab: hide/unhide menu dropdown
@@ -397,7 +470,7 @@ func runWrapper() {
intercepted = true intercepted = true
suggestionsEnabled = !suggestionsEnabled suggestionsEnabled = !suggestionsEnabled
if !suggestionsEnabled { if !suggestionsEnabled {
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) writeStdout([]byte(overlay.ClearAndDisable()))
} else { } else {
shouldOverlayDraw = true shouldOverlayDraw = true
} }
@@ -408,9 +481,19 @@ func runWrapper() {
if overlay.Visible && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { if overlay.Visible && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') {
intercepted = true intercepted = true
userNavigated = true userNavigated = true
overlay.UserNavigated = true
_, _ = os.Stdout.Write([]byte(overlay.Clear())) // clear old menu // clear ghost text synchronously
if overlay.LastGhostLen > 0 {
var gs strings.Builder
gs.WriteString("\0337")
gs.WriteString(strings.Repeat(" ", overlay.LastGhostLen+10))
gs.WriteString("\0338")
writeStdout([]byte(gs.String()))
overlay.LastGhostLen = 0
}
oldCursor := overlay.Cursor
if inputSlice[i+2] == 'A' { // up arrow if inputSlice[i+2] == 'A' { // up arrow
overlay.Cursor-- overlay.Cursor--
if overlay.Cursor < 0 { if overlay.Cursor < 0 {
@@ -423,20 +506,36 @@ func runWrapper() {
} }
} }
selected := overlay.Items[overlay.Cursor].Cmd // boundary hit - ignore redundant write to avoid PTY flooding
_, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear line if overlay.Cursor == oldCursor {
_, _ = ptmx.Write([]byte(selected)) i += 2
naiveBuffer = selected continue
}
renderNow() selected := overlay.Items[overlay.Cursor].Cmd
bufferMu.Lock()
naiveBuffer = selected
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
userNavigated = true
overlay.UserNavigated = true
renderOverlay()
i += 2 i += 2
continue continue
} else if !overlay.Visible && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt } else if !overlay.Visible && naiveBuffer == "" && (inputSlice[i+2] == 'A' || inputSlice[i+2] == 'B') { // up/down arrow on empty prompt
intercepted = true intercepted = true
mode = "history" activeModeMu.Lock()
saveMode(mode) activeMode = "history"
saveMode(activeMode)
activeModeMu.Unlock()
results := MergeResults("", "history") activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
results := MergeResults("", currentMode)
if len(results) > 0 { if len(results) > 0 {
limit := 100 limit := 100
if len(results) < limit { if len(results) < limit {
@@ -445,12 +544,12 @@ func runWrapper() {
var historyList []core.Suggestion var historyList []core.Suggestion
if inputSlice[i+2] == 'A' { if inputSlice[i+2] == 'A' {
// Up arrow: Reverse the list so newest is at the bottom // up arrow: reverse the list so newest is at the bottom
for j := limit - 1; j >= 0; j-- { for j := limit - 1; j >= 0; j-- {
historyList = append(historyList, results[j]) historyList = append(historyList, results[j])
} }
} else { } else {
// Down arrow: Normal order, newest is at the top // down arrow: normal order, newest is at the top
for j := 0; j < limit; j++ { for j := 0; j < limit; j++ {
historyList = append(historyList, results[j]) historyList = append(historyList, results[j])
} }
@@ -459,28 +558,41 @@ func runWrapper() {
overlay.UpdateItems(historyList) overlay.UpdateItems(historyList)
if inputSlice[i+2] == 'A' { if inputSlice[i+2] == 'A' {
overlay.Cursor = len(historyList) - 1 // Up arrow: Start at the bottom overlay.Cursor = len(historyList) - 1 // up arrow: start at the bottom
} else { } else {
overlay.Cursor = 0 // Down arrow: Start at the top overlay.Cursor = 0 // down arrow: start at the top
} }
selected := overlay.Items[overlay.Cursor].Cmd selected := overlay.Items[overlay.Cursor].Cmd
_, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear line bufferMu.Lock()
_, _ = ptmx.Write([]byte(selected))
naiveBuffer = selected naiveBuffer = selected
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
userNavigated = true userNavigated = true
renderNow() overlay.UserNavigated = true
renderOverlay()
} }
i += 2 i += 2
continue continue
} else if overlay.Visible && !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow } else if overlay.Visible && !disableGhostText.Load() && inputSlice[i+2] == 'C' { // right arrow
bufferMu.Lock()
topCmd := overlay.Items[0].Cmd topCmd := overlay.Items[0].Cmd
if strings.HasPrefix(strings.ToLower(topCmd), strings.ToLower(naiveBuffer)) { hasMatch := strings.HasPrefix(strings.ToLower(topCmd), strings.ToLower(naiveBuffer))
ghostText := topCmd[len(naiveBuffer):] var ghostText string
if len(ghostText) > 0 { if hasMatch {
ghostText = topCmd[len(naiveBuffer):]
}
bufferMu.Unlock()
if hasMatch && len(ghostText) > 0 {
intercepted = true intercepted = true
bufferMu.Lock()
naiveBuffer += ghostText naiveBuffer += ghostText
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write([]byte(ghostText)) _, _ = ptmx.Write([]byte(ghostText))
shouldOverlayDraw = true shouldOverlayDraw = true
i += 2 i += 2
@@ -488,13 +600,62 @@ func runWrapper() {
} }
} }
} }
// left/right arrow cursor tracking
isLeftRightArrow := false
if i+2 < n && (inputSlice[i+1] == '[' || inputSlice[i+1] == 'O') {
if inputSlice[i+2] == 'D' {
bufferMu.Lock()
isEmptyQuery := (overlay.Visible && overlay.TypedQuery == "") || (!overlay.Visible && naiveBuffer == "")
bufferMu.Unlock()
if isEmptyQuery {
intercepted = true
i += 2
continue
}
bufferMu.Lock()
if naiveBuffer != "" || overlay.Visible {
cursorOffset++
if cursorOffset > len(naiveBuffer) {
cursorOffset = len(naiveBuffer)
}
shouldOverlayDraw = true
userNavigated = false
}
bufferMu.Unlock()
isLeftRightArrow = true
} else if inputSlice[i+2] == 'C' {
bufferMu.Lock()
isEmptyQuery := (overlay.Visible && overlay.TypedQuery == "") || (!overlay.Visible && naiveBuffer == "")
bufferMu.Unlock()
if isEmptyQuery {
intercepted = true
i += 2
continue
}
bufferMu.Lock()
if naiveBuffer != "" || overlay.Visible {
cursorOffset--
if cursorOffset < 0 {
cursorOffset = 0
}
shouldOverlayDraw = true
userNavigated = false
}
bufferMu.Unlock()
isLeftRightArrow = true
}
} }
// forward escape sequence to pty if not intercepted
if !intercepted { if !intercepted {
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) writeStdout([]byte(overlay.ClearAndDisable()))
disableGhostText.Store(true) disableGhostText.Store(true)
if !isLeftRightArrow {
bufferMu.Lock()
naiveBuffer = "" naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
}
_, _ = ptmx.Write([]byte{b}) _, _ = ptmx.Write([]byte{b})
// skip remaining bytes of the escape sequence to avoid misinterpretation // skip remaining bytes of the escape sequence to avoid misinterpretation
@@ -512,12 +673,14 @@ func runWrapper() {
if b == 0x12 { // ctrl+r: toggle between command specs and command history if b == 0x12 { // ctrl+r: toggle between command specs and command history
intercepted = true intercepted = true
if mode == "spec" { activeModeMu.Lock()
mode = "history" if activeMode == "spec" {
activeMode = "history"
} else { } else {
mode = "spec" activeMode = "spec"
} }
saveMode(mode) saveMode(activeMode)
activeModeMu.Unlock()
shouldOverlayDraw = true shouldOverlayDraw = true
// enter: enter behavior is a bit different from tab suggestions in code editor // 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 // I want it to execute the command anyway and ignore the suggestions
@@ -525,10 +688,23 @@ func runWrapper() {
// enter is not used to select suggestions // enter is not used to select suggestions
} else if overlay.Visible && (b == 0x0d || b == 0x0a) { } else if overlay.Visible && (b == 0x0d || b == 0x0a) {
intercepted = true intercepted = true
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) if overlay.UserNavigated && len(overlay.Items) > 0 && overlay.Cursor >= 0 && overlay.Cursor < len(overlay.Items) {
selected := overlay.Items[overlay.Cursor].Cmd
activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
if currentMode == "spec" {
selected = strings.TrimSpace(selected) + " "
}
_, _ = ptmx.Write(append([]byte{0x15}, selected...))
}
writeStdout([]byte(overlay.ClearAndDisable()))
_, _ = ptmx.Write([]byte{0x0d}) _, _ = ptmx.Write([]byte{0x0d})
bufferMu.Lock()
naiveBuffer = "" naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
disableGhostText.Store(false) disableGhostText.Store(false)
shouldOverlayDraw = false shouldOverlayDraw = false
userNavigated = false userNavigated = false
@@ -539,16 +715,21 @@ func runWrapper() {
shouldOverlayDraw = true shouldOverlayDraw = true
} else { } else {
selected := overlay.Items[overlay.Cursor].Cmd selected := overlay.Items[overlay.Cursor].Cmd
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) writeStdout([]byte(overlay.ClearAndDisable()))
if mode == "spec" { activeModeMu.RLock()
currentMode := activeMode
activeModeMu.RUnlock()
if currentMode == "spec" {
selected = strings.TrimSpace(selected) + " " selected = strings.TrimSpace(selected) + " "
} }
bufferMu.Lock()
naiveBuffer = selected naiveBuffer = selected
cursorOffset = 0
bufferMu.Unlock()
_, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear line _, _ = ptmx.Write(append([]byte{0x15}, selected...))
_, _ = ptmx.Write([]byte(selected))
overlay.Cursor = 0 // this prevents when you tab, it switches between suggestions non-stop overlay.Cursor = 0 // this prevents when you tab, it switches between suggestions non-stop
@@ -565,13 +746,47 @@ func runWrapper() {
// we handle line editing keys manually to keep naiveBuffer in sync // we handle line editing keys manually to keep naiveBuffer in sync
// since terminal is in raw mode, we must update our state for every change // since terminal is in raw mode, we must update our state for every change
switch b { switch b {
case 127, 0x08: // backspace: remove last character from buffer case 0x01: // ctrl+a: move to beginning of line
bufferMu.Lock()
cursorOffset = len(naiveBuffer)
if naiveBuffer != "" || overlay.Visible {
shouldOverlayDraw = true
}
bufferMu.Unlock()
userNavigated = false
case 0x05: // ctrl+e: move to end of line
bufferMu.Lock()
cursorOffset = 0
if naiveBuffer != "" || overlay.Visible {
shouldOverlayDraw = true
}
bufferMu.Unlock()
userNavigated = false
case 127, 0x08: // backspace: remove character
bufferMu.Lock()
if len(naiveBuffer) > 0 { if len(naiveBuffer) > 0 {
naiveBuffer = naiveBuffer[:len(naiveBuffer)-1] runes := []rune(naiveBuffer)
if cursorOffset <= 0 {
if len(runes) > 0 {
naiveBuffer = string(runes[:len(runes)-1])
}
cursorOffset = 0
} else {
if cursorOffset > len(runes) {
cursorOffset = len(runes)
}
pos := len(runes) - cursorOffset
if pos > 0 && pos <= len(runes) {
naiveBuffer = string(append(runes[:pos-1], runes[pos:]...))
}
}
}
bufferMu.Unlock()
shouldOverlayDraw = true shouldOverlayDraw = true
userNavigated = false userNavigated = false
}
case 0x17: // ctrl+w: delete the last word in the buffer case 0x17: // ctrl+w: delete the last word in the buffer
bufferMu.Lock()
trimBuf := strings.TrimRight(naiveBuffer, " ") trimBuf := strings.TrimRight(naiveBuffer, " ")
lastSpace := strings.LastIndex(trimBuf, " ") lastSpace := strings.LastIndex(trimBuf, " ")
if lastSpace >= 0 { if lastSpace >= 0 {
@@ -579,29 +794,66 @@ func runWrapper() {
} else { } else {
naiveBuffer = "" naiveBuffer = ""
} }
cursorOffset = 0
bufferMu.Unlock()
shouldOverlayDraw = true shouldOverlayDraw = true
userNavigated = false userNavigated = false
case '\r', '\n', 0x03, 0x15, 0x0C: // enter, ctrl+c, ctrl+u, ctrl+l: clear buffer on line reset case 0x0c: // ctrl+l: clear screen but keep buffer and redraw menu
shouldOverlayDraw = true
userNavigated = false
case '\r', '\n', 0x03, 0x15: // enter, ctrl+c, ctrl+u: clear buffer on line reset
bufferMu.Lock()
naiveBuffer = "" naiveBuffer = ""
cursorOffset = 0
bufferMu.Unlock()
activeModeMu.Lock()
activeMode = loadMode()
activeModeMu.Unlock()
disableGhostText.Store(false) disableGhostText.Store(false)
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable())) writeStdout([]byte(overlay.ClearAndDisable()))
userNavigated = false userNavigated = false
default: default:
// track normal printable characters in the buffer for matching // track normal printable characters in the buffer for matching
if b >= 32 && b <= 126 { if b >= 32 && b <= 126 {
// if user presses space, check if the current word is an alias // if user presses space, check if the current word is an alias
if b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ") { bufferMu.Lock()
if target, ok := core.GetAlias(naiveBuffer); ok { isSpaceAlias := b == ' ' && naiveBuffer != "" && !strings.Contains(naiveBuffer, " ")
var target string
var ok bool
if isSpaceAlias {
target, ok = core.GetAlias(naiveBuffer)
}
bufferMu.Unlock()
if isSpaceAlias && ok {
// clear the current alias and replace it with the full command // clear the current alias and replace it with the full command
_, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear the current input line _, _ = ptmx.Write(append([]byte{0x15}, target+" "...))
_, _ = ptmx.Write([]byte(target + " ")) bufferMu.Lock()
naiveBuffer = target + " " naiveBuffer = target + " "
cursorOffset = 0
bufferMu.Unlock()
shouldOverlayDraw = true shouldOverlayDraw = true
continue continue
} }
} bufferMu.Lock()
if cursorOffset == 0 {
naiveBuffer += string(b) naiveBuffer += string(b)
} else {
if cursorOffset > len(naiveBuffer) {
cursorOffset = len(naiveBuffer)
}
pos := len(naiveBuffer) - cursorOffset
if pos >= 0 && pos <= len(naiveBuffer) {
naiveBuffer = naiveBuffer[:pos] + string(b) + naiveBuffer[pos:]
} else {
naiveBuffer += string(b)
cursorOffset = 0
}
}
bufferMu.Unlock()
shouldOverlayDraw = true shouldOverlayDraw = true
userNavigated = false
overlay.UserNavigated = false
} }
} }
} }