From fc21a6b0cc4ea13887a7809b36b72a190e715481 Mon Sep 17 00:00:00 2001 From: verse91 Date: Tue, 14 Apr 2026 12:12:07 +0700 Subject: [PATCH] fix: iris keep suggesting when press tab continually --- .gitignore | 1 + commands/core/spec.go | 166 +++++++++++++++++++++++++----- commands/dev/go.go | 3 + commands/runner/justfile.go | 4 +- justfile | 8 +- root/root.go | 198 ++++++++++++++++++++++++++---------- 6 files changed, 300 insertions(+), 80 deletions(-) diff --git a/.gitignore b/.gitignore index 58616fa..b949ae5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ iris +iris.log \ No newline at end of file diff --git a/commands/core/spec.go b/commands/core/spec.go index 56f9e30..1b1ceeb 100644 --- a/commands/core/spec.go +++ b/commands/core/spec.go @@ -1,6 +1,8 @@ package core import ( + "fmt" + "io" "os" "path/filepath" "strings" @@ -8,6 +10,14 @@ import ( "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 // Spec defines a top-level CLI command @@ -18,6 +28,7 @@ type Spec struct { Subcommands []Subcommand Options []Option Generator GeneratorFunc + MaxArgs int // 0 means unlimited } // Subcommand contains nested subcommands @@ -28,6 +39,7 @@ type Subcommand struct { Subcommands []Subcommand Options []Option Generator GeneratorFunc + MaxArgs int // 0 means unlimited } // Option represents a flag or option @@ -116,7 +128,37 @@ func Lookup(input string) []Suggestion { } 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] @@ -128,7 +170,7 @@ func Lookup(input string) []Suggestion { currentSubs, currentOpts, currentGen := spec.Subcommands, spec.Options, spec.Generator depth := 1 - for depth < len(tokens) { + for depth < len(tokens)-1 { tok := tokens[depth] if tok == "" || strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") { depth++ @@ -153,19 +195,53 @@ func Lookup(input string) []Suggestion { break } } + if found { depth++ continue } - - if currentGen != nil && depth < len(tokens)-1 { - depth++ - continue - } + // If not a subcommand, it's an argument. Stop traversal here. break } 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{} for i := 0; i < depth; i++ { if i > 0 { @@ -174,30 +250,74 @@ func Lookup(input string) []Suggestion { prefixBuilder.WriteString(tokens[i]) } prefix := prefixBuilder.String() - partial := tokens[len(tokens)-1] - if currentGen != nil { - genResults := currentGen(tokens[:depth], prefix, partial) + // 'linePrefix' is the full context except the last partial word (e.g. "just run build") + 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 { - parts := strings.Split(g.Cmd, " ") - name := parts[len(parts)-1] - if partial == "" || hasPrefix(name, partial) { + // Basic filtering based on partial input + if partial != "" && !hasPrefix(g.Cmd, partial) && !strings.Contains(g.Cmd, 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{ - Cmd: g.Cmd, Desc: g.Desc, Icon: rootCmdName, + Cmd: prefix + " " + sub.Name, Desc: sub.Description, Icon: rootCmdName, }) } } } - for _, sub := range currentSubs { - if partial == "" || hasPrefix(sub.Name, partial) { - results = append(results, Suggestion{ - Cmd: prefix + " " + sub.Name, Desc: sub.Description, Icon: rootCmdName, - }) - } - } - - if partial == "" || (len(partial) > 0 && partial[0] == '-') { + // Only suggest options if partial starts with '-' + if len(partial) > 0 && partial[0] == '-' { usedOpts := make(map[string]bool) for _, t := range tokens { if strings.HasPrefix(t, "-") { diff --git a/commands/dev/go.go b/commands/dev/go.go index f9d9929..fee524c 100644 --- a/commands/dev/go.go +++ b/commands/dev/go.go @@ -39,6 +39,7 @@ func init() { { Name: "build", Description: "compile packages and dependencies", + MaxArgs: 1, Generator: core.FileGenerator(".go"), Options: append(globalBuildOptions, core.Option{Name: ".", Description: "current package"}, @@ -52,6 +53,7 @@ func init() { { Name: "run", Description: "compile and run Go program", + MaxArgs: 1, Generator: core.FileGenerator(".go"), Options: append(globalBuildOptions, core.Option{Name: ".", Description: "current package"}, @@ -61,6 +63,7 @@ func init() { { Name: "test", Description: "test packages", + MaxArgs: 1, Generator: core.FileGenerator(".go"), Options: append(globalBuildOptions, core.Option{Name: ".", Description: "current package"}, diff --git a/commands/runner/justfile.go b/commands/runner/justfile.go index 79f6472..86c33fe 100644 --- a/commands/runner/justfile.go +++ b/commands/runner/justfile.go @@ -13,6 +13,7 @@ func init() { core.Register(&core.Spec{ Name: "just", Description: "command runner", + MaxArgs: 1, Generator: func(tokens []string, prefix string, partial string) []core.Suggestion { file, err := os.Open("justfile") if err != nil { @@ -54,9 +55,6 @@ func init() { seen[recipe] = true cmd := recipe - if prefix != "" { - cmd = prefix + " " + recipe - } desc := "just recipe" if lastComment != "" { diff --git a/justfile b/justfile index e312eed..7247f22 100644 --- a/justfile +++ b/justfile @@ -21,4 +21,10 @@ reload: # update pkg [group('dev')] pkg: - @go mod tidy \ No newline at end of file + @go mod tidy + +# debugger +[group('dev')] +debug: + @./iris --debug + diff --git a/root/root.go b/root/root.go index ac945af..2141185 100644 --- a/root/root.go +++ b/root/root.go @@ -33,15 +33,30 @@ var ( 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.`, 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() }, } - shellFlag string - isReload bool + shellFlag string + debugMode bool + debugLogger *os.File + isReload bool ) func init() { 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() { @@ -169,14 +184,16 @@ func runWrapper() { c.ExtraFiles = make([]*os.File, 11) c.ExtraFiles[10] = w c.Env = adapter.GetEnv(10, os.Getpid()) - c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} ptmx, err := pty.Start(c) if err != nil { + fmt.Fprintf(os.Stderr, "[IRIS] failed to start PTY: %v\n", err) return } defer ptmx.Close() + fmt.Printf("\033[36m[IRIS] Active - typing suggestions enabled\033[0m\r\n") + _ = pty.InheritSize(os.Stdin, ptmx) core.ShellPID = c.Process.Pid @@ -203,10 +220,9 @@ func runWrapper() { os.Setenv("IRIS_ACTIVE_SHELL", innerShell) } - // kill the child process group before reloading - // using negative PID to kill all descendants + // kill the child process before reloading if c.Process != nil { - _ = syscall.Kill(-c.Process.Pid, syscall.SIGKILL) + _ = syscall.Kill(c.Process.Pid, syscall.SIGKILL) ptmx.Close() } @@ -266,15 +282,22 @@ func runWrapper() { }() var naiveBuffer string + suggestionsEnabled := true mode := "spec" renderOverlay := func() { + if !suggestionsEnabled { + return + } // don't render if shell is starting up if isReload && naiveBuffer == "" { return } + debugLog("[Render] query: '%s', mode: %s", naiveBuffer, mode) results := mergeResults(naiveBuffer, mode) + debugLog("[Render] results found: %d", len(results)) + if len(results) == 0 { os.Stdout.Write([]byte(overlay.ClearAndDisable())) } else { @@ -299,40 +322,13 @@ func runWrapper() { shouldOverlayDraw := false for i := 0; i < n; i++ { b := inputSlice[i] - 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}) - - // arrow key monitoring with tagged switch - if b == '\033' && i+2 < n && inputSlice[i+1] == '[' { + // arrow key monitoring inside escape + if i+2 < n && inputSlice[i+1] == '[' { if overlay.Visible { switch inputSlice[i+2] { case 'A': // up @@ -350,19 +346,95 @@ func runWrapper() { } } } - - switch b { - - case 0x09: // tab - if !overlay.Visible { - shouldOverlayDraw = true + // skip sequence + for j := i + 1; j < n; j++ { + char := inputSlice[j] + ptmx.Write([]byte{char}) + i = j + 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 { naiveBuffer = naiveBuffer[:len(naiveBuffer)-1] 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 = "" mode = "spec" os.Stdout.Write([]byte(overlay.ClearAndDisable())) @@ -383,27 +455,47 @@ func runWrapper() { func mergeResults(query string, mode string) []core.Suggestion { if query == "" { + debugLog("[Merge] Query empty, returning nil") return nil } + + normalizedQuery := strings.TrimSpace(query) + seen := make(map[string]bool) + deduped := []core.Suggestion{} + if mode == "history" { histResults, _ := integration.SearchHistory(query) - cmdResults := []core.Suggestion{} 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, Desc: " history", Icon: fmt.Sprintf("%d", h.ID), }) + if len(deduped) >= 10 { + break + } } - if len(cmdResults) > 100 { - cmdResults = cmdResults[:100] - } - return cmdResults + debugLog("[Merge] History mode found %d items", len(deduped)) + return deduped } + + debugLog("[Merge] Calling Lookup for '%s'", query) cmdResults := core.Lookup(query) - seen := make(map[string]bool) - deduped := []core.Suggestion{} + debugLog("[Merge] Lookup returned %d raw items", len(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] { seen[s.Cmd] = true deduped = append(deduped, s)