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:
+88
-37
@@ -30,7 +30,7 @@ func init() {
|
||||
idMapCache = make(map[string]int)
|
||||
}
|
||||
|
||||
func SearchHistory(query string) ([]HistResult, error) {
|
||||
func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
@@ -143,68 +143,119 @@ func SearchHistory(query string) ([]HistResult, error) {
|
||||
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
|
||||
// this prevents e.g. curl commands from showing up when typing "git ..."
|
||||
queryFirstWord := ""
|
||||
if strings.IndexByte(query, ' ') != -1 {
|
||||
if fields := strings.Fields(query); len(fields) > 0 {
|
||||
queryFirstWord = strings.ToLower(fields[0])
|
||||
if qLow == tLow {
|
||||
alternativeQueries = append(alternativeQueries, name)
|
||||
} else if strings.HasPrefix(qLow, tLow+" ") {
|
||||
suffix := query[len(target):]
|
||||
alternativeQueries = append(alternativeQueries, name+suffix)
|
||||
}
|
||||
|
||||
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
|
||||
for _, m := range matches {
|
||||
if queryFirstWord != "" {
|
||||
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 {
|
||||
if seenCmds[m.Str] {
|
||||
continue
|
||||
}
|
||||
|
||||
// filter results by command name match
|
||||
firstWord := m.Str
|
||||
if idx := strings.IndexByte(m.Str, ' '); idx != -1 {
|
||||
firstWord = m.Str[:idx]
|
||||
}
|
||||
if !strings.EqualFold(firstWord, queryFirstWord) {
|
||||
continue
|
||||
firstWordLow := strings.ToLower(firstWord)
|
||||
|
||||
if queryFirstWord != "" {
|
||||
if firstWordLow != queryFirstWord {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if !strings.HasPrefix(firstWordLow, qLow) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
seenCmds[m.Str] = true
|
||||
results = append(results, HistResult{
|
||||
ID: idMapCache[m.Str],
|
||||
Cmd: m.Str,
|
||||
FuzzyScore: m.Score,
|
||||
})
|
||||
}
|
||||
results = append(results, HistResult{
|
||||
ID: idMapCache[m.Str],
|
||||
Cmd: m.Str,
|
||||
FuzzyScore: m.Score,
|
||||
})
|
||||
}
|
||||
|
||||
// within the same tier, we sort by ID
|
||||
// tier 1: exact match
|
||||
// tier 2: prefix match
|
||||
// tier 3: substring match
|
||||
// tier 4: fuzzy match
|
||||
addMatches(query)
|
||||
|
||||
for _, altQ := range alternativeQueries {
|
||||
addMatches(altQ)
|
||||
}
|
||||
|
||||
getTier := func(cmd, q string) int {
|
||||
cmdLow := strings.ToLower(cmd)
|
||||
qLow := strings.ToLower(q)
|
||||
if cmdLow == qLow {
|
||||
return 1
|
||||
bestTier := 4
|
||||
check := func(ql string) {
|
||||
cmdLow := strings.ToLower(cmd)
|
||||
qlLow := strings.ToLower(ql)
|
||||
tier := 4
|
||||
if cmdLow == qlLow {
|
||||
tier = 1
|
||||
} else if strings.HasPrefix(cmdLow, qlLow) {
|
||||
tier = 2
|
||||
} else if strings.Contains(cmdLow, qlLow) {
|
||||
tier = 3
|
||||
}
|
||||
if tier < bestTier {
|
||||
bestTier = tier
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(cmdLow, qLow) {
|
||||
return 2
|
||||
check(q)
|
||||
for _, altQ := range alternativeQueries {
|
||||
check(altQ)
|
||||
}
|
||||
if strings.Contains(cmdLow, qLow) {
|
||||
return 3
|
||||
}
|
||||
return 4
|
||||
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 {
|
||||
tI := getTier(results[i].Cmd, query)
|
||||
tJ := getTier(results[j].Cmd, query)
|
||||
tI := tiers[i]
|
||||
tJ := tiers[j]
|
||||
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 {
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
+57
-26
@@ -2,25 +2,29 @@ package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/versenilvis/iris/commands/core"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
boxWidth = 72
|
||||
maxItems = 6 // max items showing in the menu preview
|
||||
maxItems = 6
|
||||
)
|
||||
|
||||
type Overlay struct {
|
||||
mu sync.Mutex
|
||||
Visible bool
|
||||
Items []core.Suggestion
|
||||
Cursor int
|
||||
StartIdx int
|
||||
LastGhostLen int
|
||||
mu sync.Mutex
|
||||
Visible bool
|
||||
Items []core.Suggestion
|
||||
Cursor int
|
||||
StartIdx int
|
||||
LastGhostLen int
|
||||
TypedQuery string
|
||||
UserNavigated bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -53,11 +57,10 @@ func (o *Overlay) UpdateItems(items []core.Suggestion) {
|
||||
|
||||
o.Items = items
|
||||
o.Visible = len(o.Items) > 0
|
||||
o.Cursor = 0 // reset to top result on update
|
||||
o.Cursor = 0
|
||||
o.StartIdx = 0
|
||||
}
|
||||
|
||||
// fixedWidth pads or truncates a string to exact rune width
|
||||
func fixedWidth(s string, width int) string {
|
||||
runes := []rune(s)
|
||||
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
|
||||
// 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
|
||||
|
||||
if ghostText != "" || padLen > 0 {
|
||||
s.WriteString("\0337") // SAVE CURSOR at prompt
|
||||
s.WriteString("\0337") // save cursor at prompt
|
||||
if ghostText != "" {
|
||||
s.WriteString("\033[90m")
|
||||
s.WriteString(ghostText)
|
||||
@@ -106,7 +109,7 @@ func (o *Overlay) RenderGhostText(buffer string, userNavigated bool) string {
|
||||
if padLen > 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -123,16 +126,32 @@ func (o *Overlay) Render() string {
|
||||
|
||||
var s strings.Builder
|
||||
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
|
||||
if len(o.Items) < windowSize {
|
||||
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
|
||||
scrolloffDown := 0
|
||||
if windowSize <= 3 {
|
||||
@@ -159,22 +178,23 @@ func (o *Overlay) Render() string {
|
||||
start := o.StartIdx
|
||||
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 {
|
||||
s.WriteByte('\n')
|
||||
}
|
||||
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")
|
||||
|
||||
// top border with scroll indicator
|
||||
s.WriteString("\0338")
|
||||
fmt.Fprintf(&s, "\033[%dB", 1)
|
||||
s.WriteString("\033[2K")
|
||||
if offset > 0 {
|
||||
fmt.Fprintf(&s, "\033[%dD", offset)
|
||||
} else if offset < 0 {
|
||||
fmt.Fprintf(&s, "\033[%dC", -offset)
|
||||
}
|
||||
|
||||
scrollInfo := ""
|
||||
if len(o.Items) > windowSize {
|
||||
@@ -193,6 +213,11 @@ func (o *Overlay) Render() string {
|
||||
s.WriteString("\0338")
|
||||
fmt.Fprintf(&s, "\033[%dB", (i-start)+2)
|
||||
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]
|
||||
rawIcon := fixedWidth(it.Icon, iconW)
|
||||
@@ -216,14 +241,18 @@ func (o *Overlay) Render() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
s.WriteString("\0338")
|
||||
fmt.Fprintf(&s, "\033[%dB", windowSize+2)
|
||||
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) + "╯"
|
||||
s.WriteString(borderStyle.Render(bottomBorder))
|
||||
|
||||
s.WriteString("\0338") // restore to prompt
|
||||
s.WriteString("\0338")
|
||||
s.WriteString("\033[?7h")
|
||||
return s.String()
|
||||
}
|
||||
@@ -251,20 +280,22 @@ func (o *Overlay) ClearAndDisable() string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
if !o.Visible {
|
||||
if !o.Visible && len(o.Items) == 0 && o.LastGhostLen == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
o.Visible = false
|
||||
o.Items = nil
|
||||
o.TypedQuery = ""
|
||||
o.UserNavigated = false
|
||||
|
||||
var s strings.Builder
|
||||
s.WriteString("\033[?7l")
|
||||
|
||||
if o.LastGhostLen > 0 {
|
||||
s.WriteString("\0337") // save
|
||||
s.WriteString("\0337")
|
||||
s.WriteString(strings.Repeat(" ", o.LastGhostLen+10))
|
||||
s.WriteString("\0338") // restore
|
||||
s.WriteString("\0338")
|
||||
o.LastGhostLen = 0
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user