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:
+16
-2
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+88
-37
@@ -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
|
||||||
for _, m := range matches {
|
seenCmds := make(map[string]bool)
|
||||||
if queryFirstWord != "" {
|
|
||||||
|
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
|
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)
|
||||||
continue
|
|
||||||
|
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
|
addMatches(query)
|
||||||
// tier 1: exact match
|
|
||||||
// tier 2: prefix match
|
for _, altQ := range alternativeQueries {
|
||||||
// tier 3: substring match
|
addMatches(altQ)
|
||||||
// tier 4: fuzzy match
|
}
|
||||||
|
|
||||||
getTier := func(cmd, q string) int {
|
getTier := func(cmd, q string) int {
|
||||||
cmdLow := strings.ToLower(cmd)
|
bestTier := 4
|
||||||
qLow := strings.ToLower(q)
|
check := func(ql string) {
|
||||||
if cmdLow == qLow {
|
cmdLow := strings.ToLower(cmd)
|
||||||
return 1
|
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) {
|
check(q)
|
||||||
return 2
|
for _, altQ := range alternativeQueries {
|
||||||
|
check(altQ)
|
||||||
}
|
}
|
||||||
if strings.Contains(cmdLow, qLow) {
|
return bestTier
|
||||||
return 3
|
}
|
||||||
}
|
|
||||||
return 4
|
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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+57
-26
@@ -2,25 +2,29 @@ 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 {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
Visible bool
|
Visible bool
|
||||||
Items []core.Suggestion
|
Items []core.Suggestion
|
||||||
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
@@ -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
|
||||||
|
|||||||
+1
-3
@@ -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 {
|
||||||
|
|||||||
+41
-33
@@ -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)
|
debugLog("[Merge] Calling Lookup for '%s'", query)
|
||||||
if seen[normalizedCmd] {
|
cmdResults = core.Lookup(query)
|
||||||
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)
|
// search history if in history mode
|
||||||
cmdResults := core.Lookup(query)
|
var histResults []integration.HistResult
|
||||||
debugLog("[Merge] Lookup returned %d raw items", len(cmdResults))
|
if mode == "history" {
|
||||||
|
aliases := core.GetAliasesCopy()
|
||||||
|
histResults, _ = integration.SearchHistory(query, aliases)
|
||||||
|
}
|
||||||
|
|
||||||
for _, s := range cmdResults {
|
normalizedQuery := strings.TrimSpace(query)
|
||||||
|
|
||||||
|
// add suggestion helper to deduplicate
|
||||||
|
addSuggestion := func(s core.Suggestion) {
|
||||||
normalizedCmd := strings.TrimSpace(s.Cmd)
|
normalizedCmd := strings.TrimSpace(s.Cmd)
|
||||||
if normalizedCmd == normalizedQuery { // filter exact matches to avoid loops
|
if normalizedCmd == "" {
|
||||||
debugLog("[Merge] Filtered EXACT MATCH: '%s'", normalizedCmd)
|
return
|
||||||
continue
|
}
|
||||||
|
// 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]
|
||||||
}
|
}
|
||||||
|
|||||||
+379
-127
@@ -46,10 +46,22 @@ 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,115 +274,163 @@ 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() {
|
||||||
|
if isExecuting() {
|
||||||
|
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
|
||||||
|
if !navCopy {
|
||||||
|
if bufCopy == "" {
|
||||||
|
writeStdout([]byte(overlay.ClearAndDisable()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debugLog("[Render] query: '%s', mode: %s", bufCopy, modeCopy)
|
||||||
|
results := MergeResults(bufCopy, modeCopy)
|
||||||
|
debugLog("[Render] results found: %d", len(results))
|
||||||
|
|
||||||
|
if len(results) == 0 || (len(results) == 1 && strings.TrimSpace(results[0].Cmd) == strings.TrimSpace(bufCopy) && !strings.HasSuffix(bufCopy, " ")) {
|
||||||
|
b.WriteString(overlay.ClearAndDisable())
|
||||||
|
writeStdout([]byte(b.String()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if overlay.Visible {
|
||||||
|
b.WriteString(overlay.Clear())
|
||||||
|
}
|
||||||
|
overlay.TypedQuery = bufCopy
|
||||||
|
overlay.UpdateItems(results)
|
||||||
|
} else {
|
||||||
|
if overlay.Visible {
|
||||||
|
b.WriteString(overlay.Clear())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.UserNavigated = navCopy
|
||||||
|
if !disableGhostText.Load() {
|
||||||
|
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())
|
||||||
|
writeStdout([]byte(b.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNavTimerRunning bool
|
||||||
|
|
||||||
renderOverlay = func() {
|
renderOverlay = func() {
|
||||||
renderMu.Lock()
|
renderMu.Lock()
|
||||||
defer renderMu.Unlock()
|
defer renderMu.Unlock()
|
||||||
|
|
||||||
if renderTimer != nil {
|
|
||||||
renderTimer.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
if !suggestionsEnabled || isExecuting() {
|
if !suggestionsEnabled || isExecuting() {
|
||||||
|
if renderTimer != nil {
|
||||||
|
renderTimer.Stop()
|
||||||
|
renderTimer = nil
|
||||||
|
}
|
||||||
|
isNavTimerRunning = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bufCopy := naiveBuffer
|
|
||||||
modeCopy := mode
|
|
||||||
navCopy := userNavigated
|
navCopy := userNavigated
|
||||||
|
|
||||||
if bufCopy == "" && !navCopy {
|
if navCopy {
|
||||||
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
if isNavTimerRunning {
|
||||||
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() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if renderTimer != nil {
|
||||||
var b strings.Builder
|
renderTimer.Stop()
|
||||||
if !navCopy {
|
|
||||||
debugLog("[Render] query: '%s', mode: %s", bufCopy, modeCopy)
|
|
||||||
results := MergeResults(bufCopy, modeCopy)
|
|
||||||
debugLog("[Render] results found: %d", len(results))
|
|
||||||
|
|
||||||
if len(results) == 0 {
|
|
||||||
b.WriteString(overlay.ClearAndDisable())
|
|
||||||
_, _ = os.Stdout.Write([]byte(b.String()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if overlay.Visible {
|
|
||||||
b.WriteString(overlay.Clear())
|
|
||||||
}
|
|
||||||
overlay.UpdateItems(results)
|
|
||||||
} else {
|
|
||||||
if overlay.Visible {
|
|
||||||
b.WriteString(overlay.Clear())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
isNavTimerRunning = true
|
||||||
if !disableGhostText.Load() {
|
// IMPORTANT: please dont change this to above 24ms or under 19ms
|
||||||
b.WriteString(overlay.RenderGhostText(bufCopy, navCopy))
|
// 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()
|
||||||
|
renderMenuNow()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if renderTimer != nil {
|
||||||
|
renderTimer.Stop()
|
||||||
}
|
}
|
||||||
b.WriteString(overlay.Render())
|
isNavTimerRunning = false
|
||||||
_, _ = os.Stdout.Write([]byte(b.String()))
|
renderTimer = time.AfterFunc(25*time.Millisecond, func() {
|
||||||
})
|
renderMu.Lock()
|
||||||
|
renderTimer = nil
|
||||||
|
renderMu.Unlock()
|
||||||
|
renderMenuNow()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderOverlay()
|
renderOverlay()
|
||||||
|
|
||||||
renderNow := func() {
|
|
||||||
renderMu.Lock()
|
|
||||||
if renderTimer != nil {
|
|
||||||
renderTimer.Stop()
|
|
||||||
}
|
|
||||||
renderMu.Unlock()
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
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
|
||||||
// for some shortcuts like tab, enter, shift tab, ctrl r,
|
// for some shortcuts like tab, enter, shift tab, ctrl r,
|
||||||
@@ -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,42 +558,104 @@ 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 {
|
||||||
intercepted = true
|
ghostText = topCmd[len(naiveBuffer):]
|
||||||
naiveBuffer += ghostText
|
}
|
||||||
_, _ = ptmx.Write([]byte(ghostText))
|
bufferMu.Unlock()
|
||||||
shouldOverlayDraw = true
|
|
||||||
i += 2
|
if hasMatch && len(ghostText) > 0 {
|
||||||
continue
|
intercepted = true
|
||||||
}
|
bufferMu.Lock()
|
||||||
|
naiveBuffer += ghostText
|
||||||
|
cursorOffset = 0
|
||||||
|
bufferMu.Unlock()
|
||||||
|
_, _ = ptmx.Write([]byte(ghostText))
|
||||||
|
shouldOverlayDraw = true
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// forward escape sequence to pty if not intercepted
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !intercepted {
|
if !intercepted {
|
||||||
_, _ = os.Stdout.Write([]byte(overlay.ClearAndDisable()))
|
writeStdout([]byte(overlay.ClearAndDisable()))
|
||||||
disableGhostText.Store(true)
|
disableGhostText.Store(true)
|
||||||
naiveBuffer = ""
|
if !isLeftRightArrow {
|
||||||
|
bufferMu.Lock()
|
||||||
|
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
|
||||||
if len(naiveBuffer) > 0 {
|
bufferMu.Lock()
|
||||||
naiveBuffer = naiveBuffer[:len(naiveBuffer)-1]
|
cursorOffset = len(naiveBuffer)
|
||||||
|
if naiveBuffer != "" || overlay.Visible {
|
||||||
shouldOverlayDraw = true
|
shouldOverlayDraw = true
|
||||||
userNavigated = false
|
|
||||||
}
|
}
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
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, " ")
|
||||||
// clear the current alias and replace it with the full command
|
var target string
|
||||||
_, _ = ptmx.Write([]byte{0x15}) // ctrl+u to clear the current input line
|
var ok bool
|
||||||
_, _ = ptmx.Write([]byte(target + " "))
|
if isSpaceAlias {
|
||||||
naiveBuffer = target + " "
|
target, ok = core.GetAlias(naiveBuffer)
|
||||||
shouldOverlayDraw = true
|
}
|
||||||
continue
|
bufferMu.Unlock()
|
||||||
|
|
||||||
|
if isSpaceAlias && ok {
|
||||||
|
// clear the current alias and replace it with the full command
|
||||||
|
_, _ = ptmx.Write(append([]byte{0x15}, target+" "...))
|
||||||
|
bufferMu.Lock()
|
||||||
|
naiveBuffer = target + " "
|
||||||
|
cursorOffset = 0
|
||||||
|
bufferMu.Unlock()
|
||||||
|
shouldOverlayDraw = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bufferMu.Lock()
|
||||||
|
if cursorOffset == 0 {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
naiveBuffer += string(b)
|
bufferMu.Unlock()
|
||||||
shouldOverlayDraw = true
|
shouldOverlayDraw = true
|
||||||
|
userNavigated = false
|
||||||
|
overlay.UserNavigated = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user