fix: iris keep suggesting when press tab continually

This commit is contained in:
verse91
2026-04-14 12:12:07 +07:00
parent 2c01b2259e
commit fc21a6b0cc
6 changed files with 300 additions and 80 deletions
+1
View File
@@ -1 +1,2 @@
iris iris
iris.log
+143 -23
View File
@@ -1,6 +1,8 @@
package core package core
import ( import (
"fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -8,6 +10,14 @@ import (
"github.com/versenilvis/iris/integration/shell" "github.com/versenilvis/iris/integration/shell"
) )
var DebugWriter io.Writer
func debugLog(format string, a ...interface{}) {
if DebugWriter != nil {
fmt.Fprintf(DebugWriter, format+"\n", a...)
}
}
type GeneratorFunc func(tokens []string, prefix string, partial string) []Suggestion type GeneratorFunc func(tokens []string, prefix string, partial string) []Suggestion
// Spec defines a top-level CLI command // Spec defines a top-level CLI command
@@ -18,6 +28,7 @@ type Spec struct {
Subcommands []Subcommand Subcommands []Subcommand
Options []Option Options []Option
Generator GeneratorFunc Generator GeneratorFunc
MaxArgs int // 0 means unlimited
} }
// Subcommand contains nested subcommands // Subcommand contains nested subcommands
@@ -28,6 +39,7 @@ type Subcommand struct {
Subcommands []Subcommand Subcommands []Subcommand
Options []Option Options []Option
Generator GeneratorFunc Generator GeneratorFunc
MaxArgs int // 0 means unlimited
} }
// Option represents a flag or option // Option represents a flag or option
@@ -116,7 +128,37 @@ func Lookup(input string) []Suggestion {
} }
if len(tokens) == 1 { if len(tokens) == 1 {
return topLevelSuggestions(tokens[0]) query := tokens[0]
results := topLevelSuggestions(query)
// If query is a full match for a root command, also include its sub-items
if spec, exists := registry[query]; exists {
// ONLY suggest parameters if there's a trailing space in the query
// or if we are filtering. But for 'just' (len=1), if no space, wait.
hasTrailingSpace := query != "" && query[len(query)-1] == ' '
if hasTrailingSpace {
partial := ""
prefix := query
// Subcommands
for _, sub := range spec.Subcommands {
results = append(results, Suggestion{
Cmd: strings.TrimSpace(query) + " " + sub.Name, Desc: sub.Description, Icon: query,
})
}
// Generator (recipes, branches, etc.)
if spec.Generator != nil {
genResults := spec.Generator(tokens, prefix, partial)
for _, g := range genResults {
results = append(results, Suggestion{
Cmd: strings.TrimSpace(query) + " " + g.Cmd, Desc: g.Desc, Icon: query,
})
}
}
}
}
return results
} }
rootCmdName := tokens[0] rootCmdName := tokens[0]
@@ -128,7 +170,7 @@ func Lookup(input string) []Suggestion {
currentSubs, currentOpts, currentGen := spec.Subcommands, spec.Options, spec.Generator currentSubs, currentOpts, currentGen := spec.Subcommands, spec.Options, spec.Generator
depth := 1 depth := 1
for depth < len(tokens) { for depth < len(tokens)-1 {
tok := tokens[depth] tok := tokens[depth]
if tok == "" || strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") { if tok == "" || strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") {
depth++ depth++
@@ -153,19 +195,53 @@ func Lookup(input string) []Suggestion {
break break
} }
} }
if found { if found {
depth++ depth++
continue continue
} }
// If not a subcommand, it's an argument. Stop traversal here.
if currentGen != nil && depth < len(tokens)-1 {
depth++
continue
}
break break
} }
results := []Suggestion{} results := []Suggestion{}
// Track current max args
// Re-track the limit from the actual deepest subcommand reached
currentLimit := spec.MaxArgs
tempSubs := spec.Subcommands
for i := 1; i < depth; i++ {
tok := tokens[i]
for _, sub := range tempSubs {
if sub.Name == tok {
currentLimit = sub.MaxArgs
tempSubs = sub.Subcommands
break
}
}
}
// Count arguments ALREADY TYPED for this subcommand/spec
argCount := 0
for i := depth; i < len(tokens)-1; i++ {
t := tokens[i]
// Skip empty tokens or flags
if t != "" && !strings.HasPrefix(t, "-") && !strings.Contains(t, "=") {
argCount++
}
}
partial := tokens[len(tokens)-1]
// If we hit the limit, don't allow ANY more argument suggestions
// (only flags/options which start with '-' are allowed later)
allowMoreArgs := currentLimit <= 0 || argCount < currentLimit
debugLog("[Core] query tokens: %v (partial: '%s')", tokens, partial)
debugLog("[Core] depth: %d, argCount: %d, limit: %d, allowMore: %v", depth, argCount, currentLimit, allowMoreArgs)
rootCmdName = tokens[0]
// 'prefix' is the breadcrumb for the command (e.g. "git checkout")
prefixBuilder := strings.Builder{} prefixBuilder := strings.Builder{}
for i := 0; i < depth; i++ { for i := 0; i < depth; i++ {
if i > 0 { if i > 0 {
@@ -174,30 +250,74 @@ func Lookup(input string) []Suggestion {
prefixBuilder.WriteString(tokens[i]) prefixBuilder.WriteString(tokens[i])
} }
prefix := prefixBuilder.String() prefix := prefixBuilder.String()
partial := tokens[len(tokens)-1]
if currentGen != nil { // 'linePrefix' is the full context except the last partial word (e.g. "just run build")
genResults := currentGen(tokens[:depth], prefix, partial) linePrefixBuilder := strings.Builder{}
for i := 0; i < len(tokens)-1; i++ {
if i > 0 {
linePrefixBuilder.WriteByte(' ')
}
linePrefixBuilder.WriteString(tokens[i])
}
linePrefix := linePrefixBuilder.String()
// already have partial from above
rootCmdName = tokens[0]
if currentGen != nil && allowMoreArgs {
genResults := currentGen(tokens, prefix, partial)
for _, g := range genResults { for _, g := range genResults {
parts := strings.Split(g.Cmd, " ") // Basic filtering based on partial input
name := parts[len(parts)-1] if partial != "" && !hasPrefix(g.Cmd, partial) && !strings.Contains(g.Cmd, partial) {
if partial == "" || hasPrefix(name, partial) { continue
}
finalCmd := g.Cmd
// Smart Absolute Building: join fragments to the line context if needed.
// Only add linePrefix if the command doesn't already seem to have it.
cleanLinePrefix := strings.TrimSpace(linePrefix)
if cleanLinePrefix != "" && !strings.HasPrefix(finalCmd, cleanLinePrefix) && !strings.HasPrefix(finalCmd, rootCmdName) {
finalCmd = cleanLinePrefix + " " + g.Cmd
}
// UNIVERSAL DEDUPLICATION:
// Check if the NEW word being suggested is already present in the typed tokens.
newTokens := tokenize(finalCmd)
if len(newTokens) > 0 {
lastToken := newTokens[len(newTokens)-1]
isDuplicate := false
for i := 0; i < len(tokens)-1; i++ {
if tokens[i] == lastToken {
isDuplicate = true
break
}
}
if isDuplicate {
continue
}
}
results = append(results, Suggestion{
Cmd: finalCmd,
Desc: g.Desc,
Icon: rootCmdName,
})
}
}
if allowMoreArgs {
for _, sub := range currentSubs {
if partial == "" || hasPrefix(sub.Name, partial) {
results = append(results, Suggestion{ results = append(results, Suggestion{
Cmd: g.Cmd, Desc: g.Desc, Icon: rootCmdName, Cmd: prefix + " " + sub.Name, Desc: sub.Description, Icon: rootCmdName,
}) })
} }
} }
} }
for _, sub := range currentSubs { // Only suggest options if partial starts with '-'
if partial == "" || hasPrefix(sub.Name, partial) { if len(partial) > 0 && partial[0] == '-' {
results = append(results, Suggestion{
Cmd: prefix + " " + sub.Name, Desc: sub.Description, Icon: rootCmdName,
})
}
}
if partial == "" || (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, "-") {
+3
View File
@@ -39,6 +39,7 @@ func init() {
{ {
Name: "build", Name: "build",
Description: "compile packages and dependencies", Description: "compile packages and dependencies",
MaxArgs: 1,
Generator: core.FileGenerator(".go"), Generator: core.FileGenerator(".go"),
Options: append(globalBuildOptions, Options: append(globalBuildOptions,
core.Option{Name: ".", Description: "current package"}, core.Option{Name: ".", Description: "current package"},
@@ -52,6 +53,7 @@ func init() {
{ {
Name: "run", Name: "run",
Description: "compile and run Go program", Description: "compile and run Go program",
MaxArgs: 1,
Generator: core.FileGenerator(".go"), Generator: core.FileGenerator(".go"),
Options: append(globalBuildOptions, Options: append(globalBuildOptions,
core.Option{Name: ".", Description: "current package"}, core.Option{Name: ".", Description: "current package"},
@@ -61,6 +63,7 @@ func init() {
{ {
Name: "test", Name: "test",
Description: "test packages", Description: "test packages",
MaxArgs: 1,
Generator: core.FileGenerator(".go"), Generator: core.FileGenerator(".go"),
Options: append(globalBuildOptions, Options: append(globalBuildOptions,
core.Option{Name: ".", Description: "current package"}, core.Option{Name: ".", Description: "current package"},
+1 -3
View File
@@ -13,6 +13,7 @@ func init() {
core.Register(&core.Spec{ core.Register(&core.Spec{
Name: "just", Name: "just",
Description: "command runner", Description: "command runner",
MaxArgs: 1,
Generator: func(tokens []string, prefix string, partial string) []core.Suggestion { Generator: func(tokens []string, prefix string, partial string) []core.Suggestion {
file, err := os.Open("justfile") file, err := os.Open("justfile")
if err != nil { if err != nil {
@@ -54,9 +55,6 @@ func init() {
seen[recipe] = true seen[recipe] = true
cmd := recipe cmd := recipe
if prefix != "" {
cmd = prefix + " " + recipe
}
desc := "just recipe" desc := "just recipe"
if lastComment != "" { if lastComment != "" {
+7 -1
View File
@@ -21,4 +21,10 @@ reload:
# update pkg # update pkg
[group('dev')] [group('dev')]
pkg: pkg:
@go mod tidy @go mod tidy
# debugger
[group('dev')]
debug:
@./iris --debug
+145 -53
View File
@@ -33,15 +33,30 @@ var (
Long: `IRIS (a.k.a Intelligent Real-time Input Suggestion) is a shell auto-autocompletion tool. Long: `IRIS (a.k.a Intelligent Real-time Input Suggestion) is a shell auto-autocompletion tool.
It works exactly like coding editor suggestion menu drop down.`, It works exactly like coding editor suggestion menu drop down.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if debugMode {
f, _ := os.OpenFile("iris.log", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
debugLogger = f
core.DebugWriter = f // Link core logger
fmt.Fprintf(debugLogger, "--- IRIS Debug Started ---\n")
}
runWrapper() runWrapper()
}, },
} }
shellFlag string shellFlag string
isReload bool debugMode bool
debugLogger *os.File
isReload bool
) )
func init() { func init() {
rootCmd.PersistentFlags().StringVarP(&shellFlag, "shell", "s", "", "shell to use (bash, zsh, fish)") rootCmd.PersistentFlags().StringVarP(&shellFlag, "shell", "s", "", "shell to use (bash, zsh, fish)")
rootCmd.PersistentFlags().BoolVarP(&debugMode, "debug", "d", false, "enable debug logging to iris.log")
}
func debugLog(format string, a ...interface{}) {
if debugLogger != nil {
fmt.Fprintf(debugLogger, format+"\n", a...)
}
} }
func Execute() { func Execute() {
@@ -169,14 +184,16 @@ func runWrapper() {
c.ExtraFiles = make([]*os.File, 11) c.ExtraFiles = make([]*os.File, 11)
c.ExtraFiles[10] = w c.ExtraFiles[10] = w
c.Env = adapter.GetEnv(10, os.Getpid()) c.Env = adapter.GetEnv(10, os.Getpid())
c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
ptmx, err := pty.Start(c) ptmx, err := pty.Start(c)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "[IRIS] failed to start PTY: %v\n", err)
return return
} }
defer ptmx.Close() defer ptmx.Close()
fmt.Printf("\033[36m[IRIS] Active - typing suggestions enabled\033[0m\r\n")
_ = pty.InheritSize(os.Stdin, ptmx) _ = pty.InheritSize(os.Stdin, ptmx)
core.ShellPID = c.Process.Pid core.ShellPID = c.Process.Pid
@@ -203,10 +220,9 @@ func runWrapper() {
os.Setenv("IRIS_ACTIVE_SHELL", innerShell) os.Setenv("IRIS_ACTIVE_SHELL", innerShell)
} }
// kill the child process group before reloading // kill the child process before reloading
// using negative PID to kill all descendants
if c.Process != nil { if c.Process != nil {
_ = syscall.Kill(-c.Process.Pid, syscall.SIGKILL) _ = syscall.Kill(c.Process.Pid, syscall.SIGKILL)
ptmx.Close() ptmx.Close()
} }
@@ -266,15 +282,22 @@ func runWrapper() {
}() }()
var naiveBuffer string var naiveBuffer string
suggestionsEnabled := true
mode := "spec" mode := "spec"
renderOverlay := func() { renderOverlay := func() {
if !suggestionsEnabled {
return
}
// don't render if shell is starting up // don't render if shell is starting up
if isReload && naiveBuffer == "" { if isReload && naiveBuffer == "" {
return return
} }
debugLog("[Render] query: '%s', mode: %s", naiveBuffer, mode)
results := mergeResults(naiveBuffer, mode) results := mergeResults(naiveBuffer, mode)
debugLog("[Render] results found: %d", len(results))
if len(results) == 0 { if len(results) == 0 {
os.Stdout.Write([]byte(overlay.ClearAndDisable())) os.Stdout.Write([]byte(overlay.ClearAndDisable()))
} else { } else {
@@ -299,40 +322,13 @@ func runWrapper() {
shouldOverlayDraw := false shouldOverlayDraw := false
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
b := inputSlice[i] b := inputSlice[i]
intercepted := false intercepted := false
if b == 0x12 { // ctrl+r
intercepted = true
if mode == "spec" {
mode = "history"
} else {
mode = "spec"
}
shouldOverlayDraw = true
} else if overlay.Visible && (b == '\r' || b == 0x09) {
intercepted = true
selected := overlay.Items[overlay.Cursor].Cmd
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
if mode == "spec" || b == 0x09 {
selected += " "
}
naiveBuffer = selected
mode = "spec"
ptmx.Write(adapter.PrepareSelectSequence(selected))
if b == '\r' {
ptmx.Write([]byte{'\r'})
naiveBuffer = ""
} else {
renderOverlay()
}
continue
}
if !intercepted { // Detect Escape sequence (e.g. arrows) early
if b == '\033' {
ptmx.Write([]byte{b}) ptmx.Write([]byte{b})
// arrow key monitoring inside escape
// arrow key monitoring with tagged switch if i+2 < n && inputSlice[i+1] == '[' {
if b == '\033' && i+2 < n && inputSlice[i+1] == '[' {
if overlay.Visible { if overlay.Visible {
switch inputSlice[i+2] { switch inputSlice[i+2] {
case 'A': // up case 'A': // up
@@ -350,19 +346,95 @@ func runWrapper() {
} }
} }
} }
// skip sequence
switch b { for j := i + 1; j < n; j++ {
char := inputSlice[j]
case 0x09: // tab ptmx.Write([]byte{char})
if !overlay.Visible { i = j
shouldOverlayDraw = true if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '~' {
break
} }
case 127: // backspace }
continue
}
// Iris shortcuts
if b == 0x12 { // ctrl+r
intercepted = true
if mode == "spec" {
mode = "history"
} else {
mode = "spec"
}
shouldOverlayDraw = true
} else if b == 0x1b && n == 1 { // Standalone ESC (Toggle suggestions)
intercepted = true
suggestionsEnabled = !suggestionsEnabled
if !suggestionsEnabled {
debugLog("[Input] ESC pressed: hiding menu")
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
shouldOverlayDraw = false
} else {
debugLog("[Input] ESC pressed: showing menu")
shouldOverlayDraw = true
}
continue
} else if overlay.Visible && (b == 0x0d || b == 0x0a) { // Enter while menu open
intercepted = true
debugLog("[Input] Enter pressed: closing menu and executing raw buffer")
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
// Send Enter to PTY to execute current buffer as-is
ptmx.Write([]byte{0x0d})
naiveBuffer = ""
shouldOverlayDraw = false
continue
} else if b == 0x09 { // Tab (Universal Intercept - Select/Commit)
intercepted = true
if !overlay.Visible {
debugLog("[Input] Tab pressed: opening menu")
shouldOverlayDraw = true
} else {
// SELECT and COMMIT
selected := overlay.Items[overlay.Cursor].Cmd
debugLog("[Input] Tab pressed: committing '%s'", selected)
os.Stdout.Write([]byte(overlay.ClearAndDisable()))
// Auto-add space for spec mode
if mode == "spec" {
selected = strings.TrimSpace(selected) + " "
}
naiveBuffer = selected
mode = "spec"
// Sync PTY with the new buffer (Ctrl+A then Ctrl+K to clear rest)
ptmx.Write([]byte{0x01, 0x0b}) // bash/zsh style home and clear
ptmx.Write([]byte(selected))
shouldOverlayDraw = false
}
continue
}
// normal typing
if !intercepted {
ptmx.Write([]byte{b})
switch b {
case 127, 0x08: // backspace
if len(naiveBuffer) > 0 { if len(naiveBuffer) > 0 {
naiveBuffer = naiveBuffer[:len(naiveBuffer)-1] naiveBuffer = naiveBuffer[:len(naiveBuffer)-1]
shouldOverlayDraw = true shouldOverlayDraw = true
} }
case '\r', 0x03: // enter, ctrl+c case 0x17: // Ctrl+W: delete one word
trimBuf := strings.TrimRight(naiveBuffer, " ")
lastSpace := strings.LastIndex(trimBuf, " ")
if lastSpace >= 0 {
naiveBuffer = trimBuf[:lastSpace+1]
} else {
naiveBuffer = ""
}
shouldOverlayDraw = true
case '\r', 0x03, 0x15, 0x0C: // Enter, Ctrl+C, Ctrl+U, Ctrl+L
naiveBuffer = "" naiveBuffer = ""
mode = "spec" mode = "spec"
os.Stdout.Write([]byte(overlay.ClearAndDisable())) os.Stdout.Write([]byte(overlay.ClearAndDisable()))
@@ -383,27 +455,47 @@ func runWrapper() {
func mergeResults(query string, mode string) []core.Suggestion { func mergeResults(query string, mode string) []core.Suggestion {
if query == "" { if query == "" {
debugLog("[Merge] Query empty, returning nil")
return nil return nil
} }
normalizedQuery := strings.TrimSpace(query)
seen := make(map[string]bool)
deduped := []core.Suggestion{}
if mode == "history" { if mode == "history" {
histResults, _ := integration.SearchHistory(query) histResults, _ := integration.SearchHistory(query)
cmdResults := []core.Suggestion{}
for _, h := range histResults { for _, h := range histResults {
cmdResults = append(cmdResults, core.Suggestion{ normalizedCmd := strings.TrimSpace(h.Cmd)
if seen[normalizedCmd] {
continue
}
seen[normalizedCmd] = true
deduped = append(deduped, core.Suggestion{
Cmd: h.Cmd, Cmd: h.Cmd,
Desc: " history", Desc: " history",
Icon: fmt.Sprintf("%d", h.ID), Icon: fmt.Sprintf("%d", h.ID),
}) })
if len(deduped) >= 10 {
break
}
} }
if len(cmdResults) > 100 { debugLog("[Merge] History mode found %d items", len(deduped))
cmdResults = cmdResults[:100] return deduped
}
return cmdResults
} }
debugLog("[Merge] Calling Lookup for '%s'", query)
cmdResults := core.Lookup(query) cmdResults := core.Lookup(query)
seen := make(map[string]bool) debugLog("[Merge] Lookup returned %d raw items", len(cmdResults))
deduped := []core.Suggestion{}
for _, s := range cmdResults { for _, s := range cmdResults {
normalizedCmd := strings.TrimSpace(s.Cmd)
// CRITICAL: Filter out exact matches to prevent infinite tab loops
if normalizedCmd == normalizedQuery {
debugLog("[Merge] Filtered EXACT MATCH: '%s'", normalizedCmd)
continue
}
if !seen[s.Cmd] { if !seen[s.Cmd] {
seen[s.Cmd] = true seen[s.Cmd] = true
deduped = append(deduped, s) deduped = append(deduped, s)